mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-01-19 22:27:59 +01:00
Merge pull request #263 from koloml/searchbox-ac
Tags auto-completion for search queries
This commit is contained in:
commit
afdcd773bd
10 changed files with 175 additions and 39 deletions
|
@ -4,9 +4,18 @@
|
||||||
|
|
||||||
import { LocalAutocompleter } from './utils/local-autocompleter';
|
import { LocalAutocompleter } from './utils/local-autocompleter';
|
||||||
import { handleError } from './utils/requests';
|
import { handleError } from './utils/requests';
|
||||||
|
import { getTermContexts } from './match_query';
|
||||||
|
import store from './utils/store';
|
||||||
|
|
||||||
const cache = {};
|
const cache = {};
|
||||||
let inputField, originalTerm;
|
/** @type {HTMLInputElement} */
|
||||||
|
let inputField,
|
||||||
|
/** @type {string} */
|
||||||
|
originalTerm,
|
||||||
|
/** @type {string} */
|
||||||
|
originalQuery,
|
||||||
|
/** @type {TermContext} */
|
||||||
|
selectedTerm;
|
||||||
|
|
||||||
function removeParent() {
|
function removeParent() {
|
||||||
const parent = document.querySelector('.autocomplete');
|
const parent = document.querySelector('.autocomplete');
|
||||||
|
@ -18,13 +27,37 @@ function removeSelected() {
|
||||||
if (selected) selected.classList.remove('autocomplete__item--selected');
|
if (selected) selected.classList.remove('autocomplete__item--selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSearchField() {
|
||||||
|
return inputField && inputField.dataset.acMode === 'search';
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOriginalValue() {
|
||||||
|
inputField.value = isSearchField() ? originalQuery : originalTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectedValue(selection) {
|
||||||
|
if (!isSearchField()) {
|
||||||
|
inputField.value = selection;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTerm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startIndex, endIndex] = selectedTerm[0];
|
||||||
|
inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
|
||||||
|
inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
|
||||||
|
inputField.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function changeSelected(firstOrLast, current, sibling) {
|
function changeSelected(firstOrLast, current, sibling) {
|
||||||
if (current && sibling) { // if the currently selected item has a sibling, move selection to it
|
if (current && sibling) { // if the currently selected item has a sibling, move selection to it
|
||||||
current.classList.remove('autocomplete__item--selected');
|
current.classList.remove('autocomplete__item--selected');
|
||||||
sibling.classList.add('autocomplete__item--selected');
|
sibling.classList.add('autocomplete__item--selected');
|
||||||
}
|
}
|
||||||
else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
|
else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
|
||||||
inputField.value = originalTerm;
|
restoreOriginalValue();
|
||||||
removeSelected();
|
removeSelected();
|
||||||
}
|
}
|
||||||
else if (firstOrLast) { // if no item in the list is selected, select the first or last
|
else if (firstOrLast) { // if no item in the list is selected, select the first or last
|
||||||
|
@ -32,17 +65,36 @@ function changeSelected(firstOrLast, current, sibling) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSelectionOutsideCurrentTerm() {
|
||||||
|
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||||
|
const [startIndex, endIndex] = selectedTerm[0];
|
||||||
|
|
||||||
|
return startIndex > selectionIndex || endIndex < selectionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
function keydownHandler(event) {
|
function keydownHandler(event) {
|
||||||
const selected = document.querySelector('.autocomplete__item--selected'),
|
const selected = document.querySelector('.autocomplete__item--selected'),
|
||||||
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
|
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
|
||||||
lastItem = document.querySelector('.autocomplete__item:last-of-type');
|
lastItem = document.querySelector('.autocomplete__item:last-of-type');
|
||||||
|
|
||||||
|
if (isSearchField()) {
|
||||||
|
// Prevent submission of the search field when Enter was hit
|
||||||
|
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
|
||||||
|
|
||||||
|
// Close autocompletion popup when text cursor is outside current tag
|
||||||
|
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (isSelectionOutsideCurrentTerm()) removeParent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
|
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
|
||||||
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
|
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
|
||||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
||||||
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
|
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
|
||||||
const newSelected = document.querySelector('.autocomplete__item--selected');
|
const newSelected = document.querySelector('.autocomplete__item--selected');
|
||||||
if (newSelected) inputField.value = newSelected.dataset.value;
|
if (newSelected) applySelectedValue(newSelected.dataset.value);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,7 +116,7 @@ function createItem(list, suggestion) {
|
||||||
});
|
});
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
inputField.value = item.dataset.value;
|
applySelectedValue(item.dataset.value);
|
||||||
inputField.dispatchEvent(
|
inputField.dispatchEvent(
|
||||||
new CustomEvent('autocomplete', {
|
new CustomEvent('autocomplete', {
|
||||||
detail: {
|
detail: {
|
||||||
|
@ -119,9 +171,31 @@ function showAutocomplete(suggestions, fetchedTerm, targetInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSuggestions(term) {
|
function getSuggestions(term) {
|
||||||
|
// In case source URL was not given at all, do not try sending the request.
|
||||||
|
if (!inputField.dataset.acSource) return [];
|
||||||
return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json());
|
return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedTerm() {
|
||||||
|
if (!inputField || !originalQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||||
|
const terms = getTermContexts(originalQuery);
|
||||||
|
|
||||||
|
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSearchAutocomplete() {
|
||||||
|
if (!store.get('disable_search_ac')) return;
|
||||||
|
|
||||||
|
for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) {
|
||||||
|
searchField.removeAttribute('data-ac');
|
||||||
|
searchField.autocomplete = 'on';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function listenAutocomplete() {
|
function listenAutocomplete() {
|
||||||
let timeout;
|
let timeout;
|
||||||
|
|
||||||
|
@ -138,9 +212,25 @@ function listenAutocomplete() {
|
||||||
|
|
||||||
if (localAc !== null && 'ac' in event.target.dataset) {
|
if (localAc !== null && 'ac' in event.target.dataset) {
|
||||||
inputField = event.target;
|
inputField = event.target;
|
||||||
originalTerm = `${inputField.value}`.toLowerCase();
|
let suggestionsCount = 5;
|
||||||
|
|
||||||
const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
if (isSearchField()) {
|
||||||
|
originalQuery = inputField.value;
|
||||||
|
selectedTerm = getSelectedTerm();
|
||||||
|
suggestionsCount = 10;
|
||||||
|
|
||||||
|
// We don't need to run auto-completion if user is not selecting tag at all
|
||||||
|
if (!selectedTerm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
originalTerm = selectedTerm[1];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
originalTerm = `${inputField.value}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = localAc.topK(originalTerm, suggestionsCount).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
||||||
|
|
||||||
if (suggestions.length) {
|
if (suggestions.length) {
|
||||||
return showAutocomplete(suggestions, originalTerm, event.target);
|
return showAutocomplete(suggestions, originalTerm, event.target);
|
||||||
|
@ -153,9 +243,9 @@ function listenAutocomplete() {
|
||||||
originalTerm = inputField.value;
|
originalTerm = inputField.value;
|
||||||
|
|
||||||
const fetchedTerm = inputField.value;
|
const fetchedTerm = inputField.value;
|
||||||
const {ac, acMinLength} = inputField.dataset;
|
const {ac, acMinLength, acSource} = inputField.dataset;
|
||||||
|
|
||||||
if (ac && (fetchedTerm.length >= acMinLength)) {
|
if (ac && acSource && (fetchedTerm.length >= acMinLength)) {
|
||||||
if (cache[fetchedTerm]) {
|
if (cache[fetchedTerm]) {
|
||||||
showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
|
showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
|
||||||
}
|
}
|
||||||
|
@ -174,6 +264,7 @@ function listenAutocomplete() {
|
||||||
// If there's a click outside the inputField, remove autocomplete
|
// If there's a click outside the inputField, remove autocomplete
|
||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
if (event.target && event.target !== inputField) removeParent();
|
if (event.target && event.target !== inputField) removeParent();
|
||||||
|
if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent();
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchLocalAutocomplete(event) {
|
function fetchLocalAutocomplete(event) {
|
||||||
|
@ -189,6 +280,8 @@ function listenAutocomplete() {
|
||||||
.then(buf => localAc = new LocalAutocompleter(buf));
|
.then(buf => localAc = new LocalAutocompleter(buf));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSearchAutocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { listenAutocomplete };
|
export { listenAutocomplete };
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { $ } from './utils/dom';
|
import { $ } from './utils/dom';
|
||||||
import parseSearch from './match_query';
|
import { parseSearch } from './match_query';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { defaultMatcher } from './query/matcher';
|
import { defaultMatcher } from './query/matcher';
|
||||||
import { generateLexArray } from './query/lex';
|
import { generateLexArray, generateLexResult } from './query/lex';
|
||||||
import { parseTokens } from './query/parse';
|
import { parseTokens } from './query/parse';
|
||||||
import { getAstMatcherForTerm } from './query/term';
|
import { getAstMatcherForTerm } from './query/term';
|
||||||
|
|
||||||
|
@ -7,9 +7,11 @@ function parseWithDefaultMatcher(term: string, fuzz: number) {
|
||||||
return getAstMatcherForTerm(term, fuzz, defaultMatcher);
|
return getAstMatcherForTerm(term, fuzz, defaultMatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSearch(query: string) {
|
export function parseSearch(query: string) {
|
||||||
const tokens = generateLexArray(query, parseWithDefaultMatcher);
|
const tokens = generateLexArray(query, parseWithDefaultMatcher);
|
||||||
return parseTokens(tokens);
|
return parseTokens(tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default parseSearch;
|
export function getTermContexts(query: string) {
|
||||||
|
return generateLexResult(query, parseWithDefaultMatcher).termContexts;
|
||||||
|
}
|
||||||
|
|
|
@ -170,8 +170,8 @@ describe('Lexical analysis', () => {
|
||||||
expect(array).toEqual([noMatch, noMatch, 'or_op', noMatch, 'or_op', noMatch, 'or_op']);
|
expect(array).toEqual([noMatch, noMatch, 'or_op', noMatch, 'or_op', noMatch, 'or_op']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw exception on mismatched parentheses', () => {
|
it('should mark error on mismatched parentheses', () => {
|
||||||
expect(() => generateLexArray('(safe OR solo AND fluttershy', parseTerm)).toThrow('Mismatched parentheses.');
|
expect(() => generateLexArray('(safe OR solo AND fluttershy', parseTerm)).toThrow('Mismatched parentheses.');
|
||||||
// expect(() => generateLexArray(')bad', parseTerm)).toThrow('Mismatched parentheses.');
|
// expect(() => generateLexArray(')bad', parseTerm).error).toThrow('Mismatched parentheses.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -22,10 +22,18 @@ const tokenList: Token[] = [
|
||||||
|
|
||||||
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
|
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
|
||||||
|
|
||||||
export function generateLexArray(searchStr: string, parseTerm: ParseTerm): TokenList {
|
export type Range = [number, number];
|
||||||
|
export type TermContext = [Range, string];
|
||||||
|
|
||||||
|
export interface LexResult {
|
||||||
|
tokenList: TokenList,
|
||||||
|
termContexts: TermContext[],
|
||||||
|
error: ParseError | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult {
|
||||||
const opQueue: string[] = [],
|
const opQueue: string[] = [],
|
||||||
groupNegate: boolean[] = [],
|
groupNegate: boolean[] = [];
|
||||||
tokenStack: TokenList = [];
|
|
||||||
|
|
||||||
let searchTerm: string | null = null;
|
let searchTerm: string | null = null;
|
||||||
let boostFuzzStr = '';
|
let boostFuzzStr = '';
|
||||||
|
@ -35,10 +43,25 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
let fuzz = 0;
|
let fuzz = 0;
|
||||||
let lparenCtr = 0;
|
let lparenCtr = 0;
|
||||||
|
|
||||||
const pushTerm = () => {
|
let termIndex = 0;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const ret: LexResult = {
|
||||||
|
tokenList: [],
|
||||||
|
termContexts: [],
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginTerm = (token: string) => {
|
||||||
|
searchTerm = token;
|
||||||
|
termIndex = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endTerm = () => {
|
||||||
if (searchTerm !== null) {
|
if (searchTerm !== null) {
|
||||||
// Push to stack.
|
// Push to stack.
|
||||||
tokenStack.push(parseTerm(searchTerm, fuzz, boost));
|
ret.tokenList.push(parseTerm(searchTerm, fuzz, boost));
|
||||||
|
ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]);
|
||||||
// Reset term and options data.
|
// Reset term and options data.
|
||||||
boost = 1;
|
boost = 1;
|
||||||
fuzz = 0;
|
fuzz = 0;
|
||||||
|
@ -48,7 +71,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
}
|
}
|
||||||
|
|
||||||
if (negate) {
|
if (negate) {
|
||||||
tokenStack.push('not_op');
|
ret.tokenList.push('not_op');
|
||||||
negate = false;
|
negate = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -64,19 +87,19 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
const token = match[0];
|
const token = match[0];
|
||||||
|
|
||||||
if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) {
|
if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) {
|
||||||
pushTerm();
|
endTerm();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (tokenName) {
|
switch (tokenName) {
|
||||||
case 'and_op':
|
case 'and_op':
|
||||||
while (opQueue[0] === 'and_op') {
|
while (opQueue[0] === 'and_op') {
|
||||||
tokenStack.push(assertNotUndefined(opQueue.shift()));
|
ret.tokenList.push(assertNotUndefined(opQueue.shift()));
|
||||||
}
|
}
|
||||||
opQueue.unshift('and_op');
|
opQueue.unshift('and_op');
|
||||||
break;
|
break;
|
||||||
case 'or_op':
|
case 'or_op':
|
||||||
while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
|
while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
|
||||||
tokenStack.push(assertNotUndefined(opQueue.shift()));
|
ret.tokenList.push(assertNotUndefined(opQueue.shift()));
|
||||||
}
|
}
|
||||||
opQueue.unshift('or_op');
|
opQueue.unshift('or_op');
|
||||||
break;
|
break;
|
||||||
|
@ -113,10 +136,10 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
if (op === 'lparen') {
|
if (op === 'lparen') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tokenStack.push(op);
|
ret.tokenList.push(op);
|
||||||
}
|
}
|
||||||
if (groupNegate.length > 0 && groupNegate.pop()) {
|
if (groupNegate.length > 0 && groupNegate.pop()) {
|
||||||
tokenStack.push('not_op');
|
ret.tokenList.push('not_op');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -128,7 +151,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
boostFuzzStr += token;
|
boostFuzzStr += token;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
searchTerm = token;
|
beginTerm(token);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'boost':
|
case 'boost':
|
||||||
|
@ -137,7 +160,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
boostFuzzStr += token;
|
boostFuzzStr += token;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
searchTerm = token;
|
beginTerm(token);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'quoted_lit':
|
case 'quoted_lit':
|
||||||
|
@ -145,7 +168,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
searchTerm += token;
|
searchTerm += token;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
searchTerm = token;
|
beginTerm(token);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'word':
|
case 'word':
|
||||||
|
@ -159,7 +182,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
searchTerm += token;
|
searchTerm += token;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
searchTerm = token;
|
beginTerm(token);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
@ -171,6 +194,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
|
|
||||||
// Truncate string and restart the token tests.
|
// Truncate string and restart the token tests.
|
||||||
localSearchStr = localSearchStr.substring(token.length);
|
localSearchStr = localSearchStr.substring(token.length);
|
||||||
|
index += token.length;
|
||||||
|
|
||||||
// Break since we have found a match.
|
// Break since we have found a match.
|
||||||
break;
|
break;
|
||||||
|
@ -178,14 +202,24 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append final tokens to the stack.
|
// Append final tokens to the stack.
|
||||||
pushTerm();
|
endTerm();
|
||||||
|
|
||||||
if (opQueue.indexOf('rparen') !== -1 || opQueue.indexOf('lparen') !== -1) {
|
if (opQueue.indexOf('rparen') !== -1 || opQueue.indexOf('lparen') !== -1) {
|
||||||
throw new ParseError('Mismatched parentheses.');
|
ret.error = new ParseError('Mismatched parentheses.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concatenatte remaining operators to the token stack.
|
// Concatenate remaining operators to the token stack.
|
||||||
tokenStack.push(...opQueue);
|
ret.tokenList.push(...opQueue);
|
||||||
|
|
||||||
return tokenStack;
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLexArray(searchStr: string, parseTerm: ParseTerm): TokenList {
|
||||||
|
const ret = generateLexResult(searchStr, parseTerm);
|
||||||
|
|
||||||
|
if (ret.error) {
|
||||||
|
throw ret.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.tokenList;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { AstMatcher, ParseError, TokenList } from './types';
|
||||||
export function parseTokens(lexicalArray: TokenList): AstMatcher {
|
export function parseTokens(lexicalArray: TokenList): AstMatcher {
|
||||||
const operandStack: AstMatcher[] = [];
|
const operandStack: AstMatcher[] = [];
|
||||||
|
|
||||||
lexicalArray.forEach((token, i) => {
|
for (let i = 0; i < lexicalArray.length; i += 1) {
|
||||||
|
const token = lexicalArray[i];
|
||||||
|
|
||||||
if (token === 'not_op') {
|
if (token === 'not_op') {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let intermediate: AstMatcher;
|
let intermediate: AstMatcher;
|
||||||
|
@ -36,7 +38,7 @@ export function parseTokens(lexicalArray: TokenList): AstMatcher {
|
||||||
else {
|
else {
|
||||||
operandStack.push(intermediate);
|
operandStack.push(intermediate);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (operandStack.length > 1) {
|
if (operandStack.length > 1) {
|
||||||
throw new ParseError('Missing operator.');
|
throw new ParseError('Missing operator.');
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag';
|
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag';
|
||||||
import { mockStorage } from '../../../test/mock-storage';
|
import { mockStorage } from '../../../test/mock-storage';
|
||||||
import { getRandomArrayItem } from '../../../test/randomness';
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
import parseSearch from '../../match_query';
|
import { parseSearch } from '../../match_query';
|
||||||
import { SpoilerType } from '../../../types/booru-object';
|
import { SpoilerType } from '../../../types/booru-object';
|
||||||
|
|
||||||
describe('Tag utilities', () => {
|
describe('Tag utilities', () => {
|
||||||
|
|
|
@ -45,6 +45,7 @@ defmodule PhilomenaWeb.SettingController do
|
||||||
|> set_cookie(user_params, "hide_uploader", "hide_uploader")
|
|> set_cookie(user_params, "hide_uploader", "hide_uploader")
|
||||||
|> set_cookie(user_params, "hide_score", "hide_score")
|
|> set_cookie(user_params, "hide_score", "hide_score")
|
||||||
|> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
|> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
||||||
|
|> set_cookie(user_params, "disable_search_ac", "disable_search_ac")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp set_cookie(conn, params, param_name, cookie_name) do
|
defp set_cookie(conn, params, param_name, cookie_name) do
|
||||||
|
|
|
@ -12,7 +12,7 @@ header.header
|
||||||
i.fa.fa-upload
|
i.fa.fa-upload
|
||||||
|
|
||||||
= form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
|
= form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
|
||||||
input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none"
|
input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-mode="search"
|
||||||
|
|
||||||
= if present?(@conn.params["sf"]) do
|
= if present?(@conn.params["sf"]) do
|
||||||
input type="hidden" name="sf" value=@conn.params["sf"]
|
input type="hidden" name="sf" value=@conn.params["sf"]
|
||||||
|
|
|
@ -174,6 +174,10 @@ h1 Content Settings
|
||||||
=> label f, :chan_nsfw, "Show NSFW channels"
|
=> label f, :chan_nsfw, "Show NSFW channels"
|
||||||
=> checkbox f, :chan_nsfw, checked: @conn.cookies["chan_nsfw"] == "true"
|
=> checkbox f, :chan_nsfw, checked: @conn.cookies["chan_nsfw"] == "true"
|
||||||
.fieldlabel: i Show streams marked as NSFW on the channels page.
|
.fieldlabel: i Show streams marked as NSFW on the channels page.
|
||||||
|
.field
|
||||||
|
=> label f, :disable_search_ac, "Disable search auto-completion"
|
||||||
|
=> checkbox f, :disable_search_ac, checked: @conn.cookies["disable_search_ac"] === "true"
|
||||||
|
.fieldlabel: i Disable the auto-completion of tags in the search fields. This will bring back default browser's behaviour.
|
||||||
= if staff?(@conn.assigns.current_user) do
|
= if staff?(@conn.assigns.current_user) do
|
||||||
.field
|
.field
|
||||||
=> label f, :hide_staff_tools
|
=> label f, :hide_staff_tools
|
||||||
|
|
Loading…
Reference in a new issue