diff --git a/mwdb/web/package-lock.json b/mwdb/web/package-lock.json index ffa8ae588..9f084398a 100644 --- a/mwdb/web/package-lock.json +++ b/mwdb/web/package-lock.json @@ -19,8 +19,10 @@ "dagre-d3": "^0.6.3", "diff-match-patch": "^1.0.4", "identicon.js": "^2.3.2", + "jquery": "^3.7.1", "lodash": "^4.17.10", "marked": "^15.0.12", + "moo": "^0.5.2", "mustache": "^4.2.0", "popper.js": "^1.16.1", "react": "^18.1.0", @@ -38,6 +40,9 @@ "react-toastify": "^9.1.1", "readable-timestamp": "^0.2.0", "sha1": "^1.1.1", + "slate": "0.118.1", + "slate-history": "0.113.1", + "slate-react": "0.117.4", "swagger-ui-react": "^5.25.2", "typescript": "^5.0.4", "yup": "0.32.11" @@ -50,7 +55,9 @@ "@types/dagre-d3": "^0.4.39", "@types/identicon.js": "^2.3.1", "@types/jest": "^29.5.1", + "@types/jquery": "^3.5.33", "@types/marked": "^4.3.0", + "@types/moo": "^0.5.10", "@types/mustache": "^4.2.2", "@types/react": "^18.0.26", "@types/react-copy-to-clipboard": "^5.0.4", @@ -3313,6 +3320,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" + }, "node_modules/@remix-run/router": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz", @@ -4737,6 +4749,15 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/@types/jquery": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz", + "integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -4774,6 +4795,12 @@ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true }, + "node_modules/@types/moo": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@types/moo/-/moo-0.5.10.tgz", + "integrity": "sha512-W6KzyZjXUYpwQfLK1O1UDzqcqYlul+lO7Bt71luyIIyNlOZwJaNeWWdqFs1C/f2hohZvUFHMk6oFNe9Rg48DbA==", + "dev": true + }, "node_modules/@types/mustache": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", @@ -4896,6 +4923,12 @@ "@types/node": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -5793,6 +5826,11 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6436,6 +6474,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -7448,6 +7498,15 @@ } ] }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", @@ -7705,6 +7764,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -7747,6 +7811,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -10046,10 +10118,9 @@ } }, "node_modules/jquery": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", - "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==", - "peer": true + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, "node_modules/js-file-download": { "version": "0.4.12", @@ -10538,6 +10609,11 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -12061,6 +12137,14 @@ "loose-envify": "^1.1.0" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -12189,6 +12273,74 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "dev": true }, + "node_modules/slate": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.118.1.tgz", + "integrity": "sha512-6H1DNgnSwAFhq/pIgf+tLvjNzH912M5XrKKhP9Frmbds2zFXdSJ6L/uFNyVKxQIkPzGWPD0m+wdDfmEuGFH5Tg==", + "dependencies": { + "immer": "^10.0.3", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-dom": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.118.1.tgz", + "integrity": "sha512-D6J0DF9qdJrXnRDVhYZfHzzpVxzqKRKFfS0Wcin2q0UC+OnQZ0lbCGJobatVbisOlbSe7dYFHBp9OZ6v1lEcbQ==", + "peer": true, + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "slate": ">=0.99.0" + } + }, + "node_modules/slate-dom/node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "peer": true + }, + "node_modules/slate-history": { + "version": "0.113.1", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz", + "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.117.4", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.117.4.tgz", + "integrity": "sha512-9ckilyUzQS1VHJnstIpgInhcWnTDgv2Cd7m1HOQVl3zasChoapPSMftzT/wl/48grZaZYZIi4xVuzGTcFRUWFg==", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.114.0", + "slate-dom": ">=0.116.0" + } + }, + "node_modules/slate-react/node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12529,6 +12681,11 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "dev": true }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -15860,6 +16017,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" + }, "@remix-run/router": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz", @@ -17102,6 +17264,15 @@ } } }, + "@types/jquery": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz", + "integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, "@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -17139,6 +17310,12 @@ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true }, + "@types/moo": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@types/moo/-/moo-0.5.10.tgz", + "integrity": "sha512-W6KzyZjXUYpwQfLK1O1UDzqcqYlul+lO7Bt71luyIIyNlOZwJaNeWWdqFs1C/f2hohZvUFHMk6oFNe9Rg48DbA==", + "dev": true + }, "@types/mustache": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", @@ -17261,6 +17438,12 @@ "@types/node": "*" } }, + "@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -17897,6 +18080,11 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -18450,6 +18638,11 @@ "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", "dev": true }, + "direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==" + }, "dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -19175,6 +19368,11 @@ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, + "immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==" + }, "immutable": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", @@ -19348,6 +19546,11 @@ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==" }, + "is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + }, "is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -19375,6 +19578,11 @@ "has-tostringtag": "^1.0.0" } }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, "is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -21066,10 +21274,9 @@ } }, "jquery": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", - "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==", - "peer": true + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, "js-file-download": { "version": "0.4.12", @@ -21436,6 +21643,11 @@ "minimist": "^1.2.6" } }, + "moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -22510,6 +22722,14 @@ "loose-envify": "^1.1.0" } }, + "scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "requires": { + "compute-scroll-into-view": "^3.0.2" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -22599,6 +22819,66 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "dev": true }, + "slate": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.118.1.tgz", + "integrity": "sha512-6H1DNgnSwAFhq/pIgf+tLvjNzH912M5XrKKhP9Frmbds2zFXdSJ6L/uFNyVKxQIkPzGWPD0m+wdDfmEuGFH5Tg==", + "requires": { + "immer": "^10.0.3", + "tiny-warning": "^1.0.3" + } + }, + "slate-dom": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.118.1.tgz", + "integrity": "sha512-D6J0DF9qdJrXnRDVhYZfHzzpVxzqKRKFfS0Wcin2q0UC+OnQZ0lbCGJobatVbisOlbSe7dYFHBp9OZ6v1lEcbQ==", + "peer": true, + "requires": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "dependencies": { + "tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "peer": true + } + } + }, + "slate-history": { + "version": "0.113.1", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz", + "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==", + "requires": { + "is-plain-object": "^5.0.0" + } + }, + "slate-react": { + "version": "0.117.4", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.117.4.tgz", + "integrity": "sha512-9ckilyUzQS1VHJnstIpgInhcWnTDgv2Cd7m1HOQVl3zasChoapPSMftzT/wl/48grZaZYZIi4xVuzGTcFRUWFg==", + "requires": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "dependencies": { + "tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + } + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -22872,6 +23152,11 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "dev": true }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", diff --git a/mwdb/web/package.json b/mwdb/web/package.json index f11ab84d1..37e404912 100644 --- a/mwdb/web/package.json +++ b/mwdb/web/package.json @@ -22,8 +22,10 @@ "dagre-d3": "^0.6.3", "diff-match-patch": "^1.0.4", "identicon.js": "^2.3.2", + "jquery": "^3.7.1", "lodash": "^4.17.10", "marked": "^15.0.12", + "moo": "^0.5.2", "mustache": "^4.2.0", "popper.js": "^1.16.1", "react": "^18.1.0", @@ -41,6 +43,9 @@ "react-toastify": "^9.1.1", "readable-timestamp": "^0.2.0", "sha1": "^1.1.1", + "slate": "0.118.1", + "slate-history": "0.113.1", + "slate-react": "0.117.4", "swagger-ui-react": "^5.25.2", "typescript": "^5.0.4", "yup": "0.32.11" @@ -53,7 +58,9 @@ "@types/dagre-d3": "^0.4.39", "@types/identicon.js": "^2.3.1", "@types/jest": "^29.5.1", + "@types/jquery": "^3.5.33", "@types/marked": "^4.3.0", + "@types/moo": "^0.5.10", "@types/mustache": "^4.2.2", "@types/react": "^18.0.26", "@types/react-copy-to-clipboard": "^5.0.4", diff --git a/mwdb/web/src/components/RecentView/Views/RecentView.tsx b/mwdb/web/src/components/RecentView/Views/RecentView.tsx index d9957896f..996a6d79b 100644 --- a/mwdb/web/src/components/RecentView/Views/RecentView.tsx +++ b/mwdb/web/src/components/RecentView/Views/RecentView.tsx @@ -9,6 +9,8 @@ import { RecentViewList } from "./RecentViewList"; import { QuickQuery } from "../common/QuickQuery"; import { ObjectType } from "@mwdb-web/types/types"; import { AxiosError } from "axios"; +import { QueryInput } from "../common/QueryInput"; +import { useQuerySuggestions } from "../common/useQuerySuggestions"; type Props = { type: ObjectType; @@ -30,6 +32,10 @@ export function RecentView(props: Props) { }> | null>(null); const [objectCount, setObjectCount] = useState(null); const countingEnabled = searchParams.get("count") === "1" ? 1 : 0; + const [suggestions, loadingSuggestions] = useQuerySuggestions( + queryInput, + props.type + ); const setCurrentQuery = useCallback( (query: string) => { @@ -137,7 +143,7 @@ export function RecentView(props: Props) { setCurrentQuery(queryInput); }} > -
+
{ ev.preventDefault(); setCurrentQuery(""); + setQueryInput(""); }} />
- setQueryInput(evt.target.value)} + onChange={(currentValue) => + setQueryInput(currentValue) + } + onSubmit={() => { + setCurrentQuery(queryInput); + }} + suggestions={suggestions} + loadingSuggestions={loadingSuggestions} />
diff --git a/mwdb/web/src/components/RecentView/common/QueryInput.tsx b/mwdb/web/src/components/RecentView/common/QueryInput.tsx new file mode 100644 index 000000000..d6d9fd24c --- /dev/null +++ b/mwdb/web/src/components/RecentView/common/QueryInput.tsx @@ -0,0 +1,292 @@ +import React, { + CSSProperties, + forwardRef, + useCallback, + useEffect, + useState, +} from "react"; +import { + createEditor, + Transforms, + Text, + Descendant, + NodeEntry, + DecoratedRange, + Editor, +} from "slate"; +import { + Slate, + Editable, + withReact, + RenderLeafProps, + ReactEditor, +} from "slate-react"; +import { withHistory } from "slate-history"; +import $ from "jquery"; +import { + FIELD_TYPES, + annotateQuery, + QueryAnnotation, + OPER_TYPES, +} from "../common/luceneLexer"; +import { QuerySuggestion } from "./useQuerySuggestions"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; + +const withSingleLine = (editor: ReactEditor): ReactEditor => { + const { normalizeNode } = editor; + + editor.normalizeNode = ([node, path]) => { + if (path.length === 0) { + if (editor.children.length > 1) { + Transforms.mergeNodes(editor); + } + } + return normalizeNode([node, path]); + }; + return editor; +}; + +function Leaf({ attributes, children, leaf }: RenderLeafProps) { + let style: CSSProperties = {}; + let annotation: QueryAnnotation = (leaf as any).annotation; + if (annotation) { + if (annotation.lastOpenedBracket) { + // Style of opened bracket + style = { + color: "aqua", + fontWeight: "bolder", + }; + } else if (OPER_TYPES.includes(annotation.type)) { + // Style of keyword operators + style = { + color: "blue", + }; + } else if (FIELD_TYPES.includes(annotation.type)) { + // Style of field names + style = { + color: "purple", + }; + } else if (annotation.type === "value_phrase") { + // Style of quoted value (phrase) + style = { + color: "green", + }; + } else if (annotation.error) { + // Style of fragment with syntax error + style = { + textDecorationLine: "underline", + textDecorationColor: "red", + textDecorationStyle: "wavy", + color: "red", + }; + } + } + return ( + + {children} + + ); +} + +type SuggestionButtonProps = { + children: React.ReactNode; + description?: string; + onClick?: React.MouseEventHandler; +}; + +function SuggestionButton({ + children, + description, + onClick, +}: SuggestionButtonProps) { + return ( + + ); +} + +type QueryInputProps = { + value: string; + onChange: (currentValue: string) => void; + onSubmit: () => void; + suggestions: QuerySuggestion[]; + loadingSuggestions: boolean; +}; + +export function QueryInput(props: QueryInputProps) { + const [editor, setEditor] = useState(() => + withSingleLine(withReact(withHistory(createEditor()))) + ); + const [value, setValue] = useState([ + { + type: "paragraph", + children: [{ text: props.value }], + } as Descendant, + ]); + const [rawValue, setRawValue] = useState(props.value); + const showSuggestions = + props.suggestions.length > 0 || props.loadingSuggestions; + + useEffect(() => { + if (rawValue === props.value) { + return; + } + // There is no easy way in Slate to update text value + // Fortunately, our Descendant tree contains only single + // text node, thanks to "withSingleLine" + const children = [...editor.children]; + children.forEach((node) => + editor.apply({ type: "remove_node", path: [0], node }) + ); + editor.apply({ + type: "insert_node", + path: [0], + node: { + type: "paragraph", + children: [{ text: props.value }], + } as Descendant, + }); + setRawValue(props.value); + }, [editor, props.value, rawValue, setRawValue]); + + useEffect(() => { + const toggleElement = $("#query-input") as any; + if (showSuggestions && ReactEditor.isFocused(editor)) { + toggleElement.dropdown("show"); + toggleElement.dropdown("update"); + } else { + toggleElement.dropdown("hide"); + } + }, [showSuggestions, editor]); + + const updateValue = useCallback( + (newValue: any[]) => { + setValue(newValue); + let rawValue = newValue[0]?.children[0].text; + if (typeof rawValue !== "undefined") { + setRawValue(rawValue); + props.onChange(rawValue); + } + }, + [setValue, props.onChange] + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + props.onSubmit(); + } + }, + [props.onSubmit] + ); + + const decorate = useCallback( + ([node, path]: NodeEntry): DecoratedRange[] => { + if (!Text.isText(node)) return []; + const ranges = []; + const { annotations } = annotateQuery(node.text); + for (let annotation of annotations) { + ranges.push({ + annotation, + anchor: { path, offset: annotation.offset }, + focus: { + path, + offset: annotation.offset + annotation.value.length, + }, + }); + } + return ranges; + }, + [] + ); + + const applySuggestion = useCallback( + (applyFn: (value: string) => string) => { + props.onChange(applyFn(rawValue)); + // Recover focus onto QueryInput + // We need to do it after other actions in event loop + setTimeout(() => { + ReactEditor.focus(editor); + Transforms.select(editor, Editor.end(editor, [])); + }, 0); + }, + [rawValue, props.onChange] + ); + + return ( + <> + + ( +
+

{children}

+
+ )} + className="form-control small" + as={forwardRef(({ children, ...props }, ref) => ( +
+ {children} +
+ ))} + style={{ + whiteSpace: "pre", + border: "1px solid gray", + padding: "6px 8px", + borderRadius: "4px", + width: "250px", + overflowX: "auto", + }} + /> +
+ {!props.loadingSuggestions ? ( + props.suggestions.map((suggestion: QuerySuggestion) => { + return ( + { + if (suggestion.apply) + applySuggestion(suggestion.apply); + }} + > + {suggestion.suggestion} + + ); + }) + ) : ( + + + + )} +
+
+ + ); +} diff --git a/mwdb/web/src/components/RecentView/common/luceneLexer.ts b/mwdb/web/src/components/RecentView/common/luceneLexer.ts new file mode 100644 index 000000000..68f9a5406 --- /dev/null +++ b/mwdb/web/src/components/RecentView/common/luceneLexer.ts @@ -0,0 +1,201 @@ +import moo from "moo"; + +const lexer = moo.states({ + expression: { + expr_boolop: /(?:AND|OR)/, + expr_notop: "NOT", + expr_phrase: /"(?:\\.|[^\n"\\])*"/, + expr_fieldsep: ".", + expr_term: /(?:\w|\\.)+/, + expr_arrayref: "*", + expr_valuesep: { match: ":", next: "value" }, + expr_lpar: "(", + expr_rpar: ")", + expr_space: { match: /\s+/, lineBreaks: true }, + }, + value: { + value_compop: /[><][=]?/, + value_lrange: { match: /[\[{]/, next: "range" }, + value_lpar: { match: "(", next: "expression" }, + value_phrase: { match: /"(?:\\.|[^\n"\\])*"/, next: "expression" }, + value_term: { match: /(?:\w|\\.|[*?\-_.])+/, next: "expression" }, + }, + range: { + range_rrange: { match: /[}\]]/, next: "expression" }, + range_toop: "TO", + range_phrase: /"(?:\\["\\]|[^\n"\\])*"/, + range_term: /(?:\w|[*?\-_.])+/, + range_space: { match: /\s+/, lineBreaks: true }, + }, + unfinished_phrase: {}, +}); + +/** + * Making a full-blown parser would be too complicated here. + * Instead, we use lexer and simple state machine + */ +const allowedStates: { [state: string]: { [expectedLexem: string]: string } } = + { + // Beginning of the expression + main: { + expr_notop: "main", + expr_lpar: "main", + expr_space: "main", + expr_phrase: "fieldsep", + expr_term: "fieldsep", + }, + // End of field name, field separator ('.') is expected + fieldsep: { + expr_valuesep: "value_start", + expr_fieldsep: "fieldname", + expr_arrayref: "fieldsep", + }, + // Field name after field separator ('.') + fieldname: { + expr_phrase: "fieldsep", + expr_term: "fieldsep", + }, + // Field value after value separator (':') + value_start: { + value_compop: "value", + value_lrange: "range_start", + value_lpar: "main", + value_phrase: "value_end", + value_term: "value_end", + }, + // Value preceded by comparison operator (e.g. 'size:>25') + value: { + value_phrase: "value_end", + value_term: "value_end", + }, + // End of value, boolean operator or ')' expected + value_end: { + expr_boolop: "after_boolop", + expr_space: "value_end", + expr_rpar: "value_end", + }, + // Boolean operator must be followed by space or '(' + after_boolop: { + expr_space: "main", + expr_lpar: "main", + }, + // First element of range (after '[' or '{') + range_start: { + range_phrase: "range_to", + range_term: "range_to", + }, + // TO operator expected + range_to: { + range_space: "range_to", + range_toop: "range_end", + }, + // Second element of range expected (after 'TO') + range_end: { + range_space: "range_end", + range_phrase: "range_fin", + range_term: "range_fin", + }, + // After second element of range, end of range expected (']' or '}') + range_fin: { + range_space: "range_fin", + range_rrange: "value_end", + }, + }; + +// Left parentheses types (for parentheses counting) +export const LPAR_TYPES: string[] = ["expr_lpar", "value_lpar", "value_lrange"]; +// Right parentheses types (for parentheses counting) +export const RPAR_TYPES: string[] = ["expr_rpar", "range_rrange"]; +// Field name parts +export const FIELD_TYPES: string[] = ["expr_phrase", "expr_term"]; +// Other expected tokens being a part of field name +export const FIELD_PART_TYPES: string[] = ["expr_arrayref", "expr_fieldsep"]; +// Keyword operators +export const OPER_TYPES: string[] = [ + "expr_notop", + "expr_boolop", + "range_toop", + "value_compop", +]; + +export function* lexQuery( + query: string +): Generator<[moo.Token, string, string[]]> { + let currentState = "main"; + lexer.reset(query); + for (let token of lexer) { + if (!token.type || !allowedStates[currentState][token.type]) + throw new Error( + `${token.type} is not allowed in '${currentState}' state` + ); + currentState = allowedStates[currentState][token.type]; + yield [token, currentState, Object.keys(allowedStates[currentState])]; + } +} + +export type QueryAnnotation = { + type: string; + value: string; + offset: number; + lastOpenedBracket?: boolean; + error?: string; +}; + +export type AnnotatedQuery = { + annotations: QueryAnnotation[]; + nextPossibleTokens: string[]; +}; + +export function annotateQuery(query: string): AnnotatedQuery { + const annotations: QueryAnnotation[] = []; + const openedParentheses: QueryAnnotation[] = []; + + let nextOffset = 0; + let nextPossibleTokens: string[] = []; + try { + if (!query.length) { + // If query is empty: at least extract next possible tokens + nextPossibleTokens = lexQuery(" ").next().value[2]; + } else { + for (const [token, _, nextTokens] of lexQuery(query)) { + let tokenType: string = token.type as string; + let annotation: QueryAnnotation = { + type: tokenType, + value: token.value, + offset: token.offset, + }; + if (LPAR_TYPES.includes(tokenType)) { + if (openedParentheses.length > 0) { + openedParentheses[ + openedParentheses.length - 1 + ].lastOpenedBracket = false; + } + annotation.lastOpenedBracket = true; + openedParentheses.push(annotation); + } else if (RPAR_TYPES.includes(tokenType)) { + openedParentheses[ + openedParentheses.length - 1 + ].lastOpenedBracket = false; + openedParentheses.pop(); + if (openedParentheses.length > 0) { + openedParentheses[ + openedParentheses.length - 1 + ].lastOpenedBracket = true; + } + } + annotations.push(annotation); + nextOffset = token.offset + token.value.length; + nextPossibleTokens = nextTokens; + } + } + } catch (e: any) { + annotations.push({ + type: "error", + value: query.slice(nextOffset), + error: e.toString(), + offset: nextOffset, + }); + nextPossibleTokens = []; + } + return { annotations, nextPossibleTokens }; +} diff --git a/mwdb/web/src/components/RecentView/common/useQuerySuggestions.ts b/mwdb/web/src/components/RecentView/common/useQuerySuggestions.ts new file mode 100644 index 000000000..b2efbc22e --- /dev/null +++ b/mwdb/web/src/components/RecentView/common/useQuerySuggestions.ts @@ -0,0 +1,451 @@ +import { AttributeDefinition, ObjectType } from "@mwdb-web/types/types"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + annotateQuery, + FIELD_PART_TYPES, + FIELD_TYPES, + LPAR_TYPES, + RPAR_TYPES, +} from "@mwdb-web/components/RecentView/common/luceneLexer"; +import { api } from "@mwdb-web/commons/api"; +import { toast } from "react-toastify"; + +export type QuerySuggestion = { + suggestion: string; + description?: string; + apply?: (currentQuery: string) => string; +}; + +type FieldDefinition = { + description: string; + subfields?: boolean; + subquery?: boolean; +}; + +const fieldDefinitions: Record< + ObjectType, + { [field: string]: FieldDefinition } +> = { + object: { + dhash: { + description: "Data hash (sha256)", + }, + tag: { + description: "Query objects with provided tag", + }, + comment: { + description: "Query for comment contents", + }, + meta: { + description: + "Query for object attribute value (alias for 'attribute')", + subfields: true, + }, + attribute: { + description: "Query for object attribute value", + subfields: true, + }, + shared: { + description: "Query for objects shared with provided user or group", + }, + sharer: { + description: "Query for objects shared by provided user or group", + }, + uploader: { + description: "Query for objects uploaded by provided user or group", + }, + upload_time: { + description: "Query for objects uploaded at provided timestamp", + }, + parent: { + description: + "Query for objects having parent that matches the condition", + subquery: true, + }, + child: { + description: + "Query for objects having child that matches the condition", + subquery: true, + }, + favorites: { + description: "Query for favorite objects of given user", + }, + karton: { + description: + "Query for objects related with Karton analysis identifier", + }, + comment_author: { + description: "Query for objects commented by given user", + }, + upload_count: { + description: + "Query for objects uploaded by given amount of users (including parents)", + }, + }, + file: { + name: { + description: "Query for file having a provided name", + }, + size: { + description: "Query for file having a provided size", + }, + type: { + description: "Query for file having a provided magic type", + }, + md5: { + description: "Query for file having a provided MD5 hash", + }, + sha1: { + description: "Query for file having a provided SHA1 hash", + }, + sha256: { + description: "Query for file having a provided SHA256 hash", + }, + sha512: { + description: "Query for file having a provided SHA512 hash", + }, + ssdeep: { + description: "Query for file having a provided ssdeep hash", + }, + crc32: { + description: "Query for file having a provided crc32 hash", + }, + multi: { + description: "Query for file that matches provided list of IoCs", + }, + }, + config: { + type: { + description: "Query for config having a provided config type", + }, + family: { + description: "Query for config for provided malware family", + }, + cfg: { + description: "Query for config field value", + subfields: true, + }, + multi: { + description: "Query for config that matches provided list of IoCs", + }, + }, + blob: { + name: { + description: "Query for blob having a provided name", + }, + size: { + description: "Query for blob having a provided size", + }, + type: { + description: "Query for blob having a provided type", + }, + content: { + description: "Query for blob contents", + }, + first_seen: { + description: + "Query for blob uploaded first time at provided timestamp", + }, + last_seen: { + description: + "Query for blob uploaded last time at provided timestamp", + }, + multi: { + description: "Query for blob that matches provided list of IoCs", + }, + }, +}; + +function getCurrentField(currentQuery: string): [string[], boolean] { + /** + * Returns current field path and boolean indicating if we're inside subquery + * If current field path is an empty array: there is no pending field input + */ + const annotatedQuery = annotateQuery(currentQuery); + let currentField: string[] = []; + let bracketStack: string[] = []; + for (let annotation of annotatedQuery.annotations) { + if (FIELD_TYPES.includes(annotation.type)) { + currentField.push(annotation.value); + } else if (!FIELD_PART_TYPES.includes(annotation.type)) { + currentField = []; + } + + if (LPAR_TYPES.includes(annotation.type)) { + bracketStack.push(annotation.type); + } else if (RPAR_TYPES.includes(annotation.type)) { + bracketStack.pop(); + } + } + if ( + annotatedQuery.nextPossibleTokens.some((tokenType) => + FIELD_TYPES.includes(tokenType) + ) + ) { + // If next field name part is expected, but there is no token: put empty string + currentField.push(""); + } + return [ + currentField, + bracketStack.some((fieldType) => fieldType === "value_lpar"), + ]; +} + +function makeSuggestion( + partialInput: string, + suggestedInput: string, + definition: FieldDefinition, + prefix: string = "" +): QuerySuggestion | null { + let suggestion = prefix + suggestedInput; + if (!suggestion.startsWith(partialInput)) { + return null; + } + if (definition.subquery) { + suggestion += ":("; + } else if (definition.subfields) { + suggestion += "."; + } else { + suggestion += ":"; + } + return { + suggestion, + description: definition.description, + apply: (currentQuery) => + currentQuery.slice(0, currentQuery.length - partialInput.length) + + suggestion, + }; +} + +async function fetchSuggestions( + currentQuery: string, + objectType: ObjectType, + subfieldSuggestionGetters: Record< + string, + (currentField: string[]) => Promise + > +): Promise { + const [currentField, insideSubquery] = getCurrentField(currentQuery); + const suggestions: QuerySuggestion[] = []; + if (!currentField.length) return []; + let lastField = currentField[currentField.length - 1]; + // Handle untyped query + if (objectType === "object" || insideSubquery) { + if (currentField.length === 1) { + // If doesn't contain type selector: generate untyped... + for (let objectField of Object.keys(fieldDefinitions.object)) { + let suggestion = makeSuggestion( + lastField, + objectField, + fieldDefinitions.object[objectField] + ); + if (suggestion) { + suggestions.push(suggestion); + } + } + // ... and then typed suggestions with type selector prefix + for (let objectType of ["file", "config", "blob"] as ObjectType[]) { + for (let typedField of Object.keys( + fieldDefinitions[objectType] + )) { + let suggestion = makeSuggestion( + lastField, + typedField, + fieldDefinitions[objectType][typedField], + objectType + "." + ); + if (suggestion) { + suggestions.push(suggestion); + } + } + } + return suggestions; + } else { + // If contains type selector: set it as object type + // and shift currentField + if (Object.keys(fieldDefinitions).includes(currentField[0])) { + objectType = currentField.shift() as ObjectType; + } + } + } + // Here type selector is gone + if (currentField.length === 1) { + // If current field path doesn't contain any subfields + // then get field suggestions + const definitions = { + ...fieldDefinitions.object, + ...fieldDefinitions[objectType], + }; + for (let field of Object.keys(definitions)) { + let suggestion = makeSuggestion( + lastField, + field, + definitions[field] + ); + if (suggestion) { + suggestions.push(suggestion); + } + } + return suggestions; + } else { + // If we're in the subfield, it's possible + // that we need to fetch suggestion information + // from API + const getSubfieldSuggestion = + subfieldSuggestionGetters[currentField[0]]; + if (!getSubfieldSuggestion) { + return []; + } + return await getSubfieldSuggestion(currentField); + } +} + +function getStructureFromValue(value: any): any { + if (Array.isArray(value)) { + let structure = {}; + for (let el of value) { + Object.assign(structure, getStructureFromValue(el)); + } + return structure; + } else if (typeof value === "object") { + let structure: { [key: string]: any } = {}; + for (let [key, val] of Object.entries(value)) { + structure[key] = getStructureFromValue(val); + } + return structure; + } else { + return {}; + } +} + +type AttributesStructure = { + descriptions: { [key: string]: string }; + values: { [key: string]: any }; +}; + +function getAttributesStructure( + attributeDefinitions: AttributeDefinition[] +): AttributesStructure { + const values: { [key: string]: any } = {}; + const descriptions: { [key: string]: string } = {}; + for (let attributeDefinition of attributeDefinitions) { + descriptions[attributeDefinition.key] = attributeDefinition.description; + values[attributeDefinition.key] = {}; + if (attributeDefinition.example_value) { + try { + const exampleValue = JSON.parse( + attributeDefinition.example_value + ); + values[attributeDefinition.key] = + getStructureFromValue(exampleValue); + } catch (e) { + console.error( + "Failed to parse example value, ignoring that attribute definition" + ); + } + } + } + return { descriptions, values }; +} + +function useAttributesStructure(): () => Promise { + // We want to fetch attributes only once, but there may be multiple requests for it + // as user is typing the search input containing "attribute." field. + // That's why: + // - we create promise only once and then use it multiple times + // (multiple loader invocations await on the same promise) + // - resolved promise effectively keeps the information about attributes + const promise = useRef | null>(null); + return useCallback(async () => { + if (!promise.current) { + promise.current = new Promise((resolve, reject) => { + api.getAttributeDefinitions("read") + .then((response) => { + let attributesStructure = getAttributesStructure( + response.data["attribute_definitions"] + ); + resolve(attributesStructure); + }) + .catch((error) => { + toast(error.toString(), { type: "error" }); + }); + }); + } + return await promise.current; + }, []); +} + +function getAttributeSuggestions( + currentField: string[], + attributeStructure: AttributesStructure +): QuerySuggestion[] { + if (currentField.length <= 1) return []; + if (currentField.length == 2) { + let suggestions = []; + for (let attributeKey of Object.keys(attributeStructure.descriptions)) { + let suggestion = makeSuggestion(currentField[1], attributeKey, { + description: attributeStructure.descriptions[attributeKey], + subfields: + Object.keys(attributeStructure.values[attributeKey] || {}) + .length > 0, + }); + if (suggestion) suggestions.push(suggestion); + } + return suggestions; + } else { + let suggestions = []; + const attributeKey = currentField[1]; + let values = attributeStructure.values[attributeKey] || {}; + for (let field of currentField.slice(2, currentField.length - 1)) { + if (!Object.keys(values[field] || {}).length) { + return []; + } + values = values[field] || {}; + } + let lastField = currentField[currentField.length - 1]; + for (let subfield of Object.keys(values)) { + let suggestion = makeSuggestion(lastField, subfield, { + description: "", + subfields: Object.keys(values[subfield] || {}).length > 0, + }); + if (suggestion) suggestions.push(suggestion); + } + return suggestions; + } +} + +export function useQuerySuggestions( + currentQuery: string, + objectType: ObjectType +): [QuerySuggestion[], boolean] { + const loadAttributesStructure = useAttributesStructure(); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchAttributeSuggestions = useCallback( + async (currentField: string[]) => { + let attributesStructure = await loadAttributesStructure(); + return getAttributeSuggestions(currentField, attributesStructure); + }, + [loadAttributesStructure] + ); + + useEffect(() => { + let valid = true; + fetchSuggestions(currentQuery, objectType, { + attribute: fetchAttributeSuggestions, + meta: fetchAttributeSuggestions, + }).then((suggestions) => { + if (valid) { + setSuggestions(suggestions); + setLoading(false); + } + }); + setLoading(true); + return () => { + valid = false; + }; + }, [currentQuery, objectType, setSuggestions, fetchAttributeSuggestions]); + + return [suggestions, loading]; +}