Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lxl-web, supersearch): Narrow search query when editing parts (LWS-273) #1190

Merged
merged 12 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions lxl-web/src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@
language={lxlQuery}
placeholder={$page.data.t('search.search')}
endpoint={'/api/supersearch'}
queryFn={(query) =>
new URLSearchParams({
queryFn={(query, cursor) => {
return new URLSearchParams({
_q: query,
_limit: '10'
})}
_limit: '10',
cursor: cursor.toString()
});
}}
paginationQueryFn={handlePaginationQuery}
extensions={[lxlQualifierPlugin]}
>
Expand Down
25 changes: 24 additions & 1 deletion lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,35 @@ import type { RequestHandler } from './$types.ts';
import { LxlLens } from '$lib/types/display';
import { getSupportedLocale } from '$lib/i18n/locales.js';
import { toString } from '$lib/utils/xl.js';
import getEditedPartEntries from './getEditedPartEntries.js';

/**
* TODO:
* - Investigate how we should also send the full query if we wish to boost faceted results.
* - Investigate if we also should do a separate query for the last edited word – and not only the whole phrase (e.g. should we show a result for the subject `winter` when entering `astrid lindgren winter`?)
*/

export const GET: RequestHandler = async ({ url, params, locals }) => {
const displayUtil = locals.display;
const locale = getSupportedLocale(params?.lang);

const findResponse = await fetch(`${env.API_URL}/find?${url.searchParams.toString()}`);
const _q = url.searchParams.get('_q');
const cursor = parseInt(url.searchParams.get('cursor') || '0', 10);

const newSearchParams = new URLSearchParams([...Array.from(url.searchParams.entries())]);

if (_q && Number.isInteger(cursor)) {
const editedPartEntries = getEditedPartEntries(_q, cursor);

editedPartEntries.forEach(([key, value]) => {
newSearchParams.set(key, value);
});
newSearchParams.delete('cursor');
console.log('Initial search params:', decodeURIComponent(url.searchParams.toString()));
console.log('Search params sent to /find:', decodeURIComponent(newSearchParams.toString()));
}

const findResponse = await fetch(`${env.API_URL}/find?${newSearchParams.toString()}`);
const data = await findResponse.json();

return json({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import getEditedPartEntries from './getEditedPartEntries';

describe('getEditedPartEntries', () => {
it('narrows down search query when editing qualifier parts', () => {
expect(getEditedPartEntries('hello title:"hej"', 16)).toEqual([['_qualifier', 'title:"hej"']]);
});
it('keeps query as is when editing year qualifiers', () => {
expect(getEditedPartEntries('hello ÅR:2024', 13)).toEqual([]);
});
it('narrows down search query by base class for query codes', () => {
expect(getEditedPartEntries('astrid lindgren subject:"winter"', 27)).toEqual([
['_qualifier', `"rdf:type":Topic "winter"`],
['min-reverseLinks.totalItems', '1']
]);
});
it('otherwise keeps query as is', () => {
expect(getEditedPartEntries('hello', 5)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import getEditedRanges from './getEditedRanges.js';

/**
* TODO: How should we handle translated query codes and qualifier keys?
*/

const QUALIFIER_KEY_BY_BASE_CLASS = {
Library: 'itemHeldBy',
Agent: 'contributor',
Topic: 'subject',
Subject: 'subject',
Language: 'SPRÅK',
GenreForm: 'genreForm',
Person: 'person',
Work: 'titel'
};

const SKIP_QUALIFIERS = ['år'];

/**
* Gets the URLSearchParams entries which should be appended/replaced with new values when editing a part of a query.
*/

function getEditedPartEntries(query: string, cursor: number): [string, string][] {
const editedRanges = getEditedRanges(query, cursor);

/**
* Narrow down search query when editing qualifier parts
*/
if (editedRanges.qualifierKey && editedRanges.qualifierOperator && editedRanges.qualifierValue) {
const qualifierKey = query.slice(editedRanges.qualifierKey.from, editedRanges.qualifierKey.to);
const qualifierOperator = query.slice(
editedRanges.qualifierOperator.from,
editedRanges.qualifierOperator.to
);
const qualifierValue = query.slice(
editedRanges.qualifierValue.from,
editedRanges.qualifierValue.to
);

if (SKIP_QUALIFIERS.includes(qualifierKey.toLowerCase())) {
return []; // Keep query as is when editing year qualifiers
}

const baseClass = Object.entries(QUALIFIER_KEY_BY_BASE_CLASS).find(
([, key]) => key === qualifierKey
)?.[0];

if (baseClass) {
return [
['_qualifier', `"rdf:type":${baseClass} ${qualifierValue}`],
['min-reverseLinks.totalItems', '1'] // ensure results are linked/used atleast once
];
}

return [['_qualifier', qualifierKey + qualifierOperator + qualifierValue]];
}

/**
* Otherwise keep query entries as is
*/
return [];
}

export default getEditedPartEntries;
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import getEditedRanges from './getEditedRanges';

describe('getEditedRanges', () => {
it('calculates the edited range for a simple free-text query', () => {
const query = 'hello';
const editedRanges = getEditedRanges(query, 5);
expect(editedRanges).toEqual({ from: 0, to: 5 });
expect(query.slice(editedRanges.from, editedRanges.to)).toBe('hello');
});

it('calculates the edited range (with included ranges of qualifier parts) when editing a qualifier', () => {
const query = 'hasTitle:"a"';
const editedRanges = getEditedRanges(query, 11);
expect(editedRanges).toEqual({
from: 0,
to: 12,
qualifierKey: {
from: 0,
to: 8
},
qualifierOperator: {
from: 8,
to: 9
},
qualifierValue: {
from: 9,
to: 12
}
});
expect(query.slice(editedRanges.from, editedRanges.to)).toBe('hasTitle:"a"');
expect(query.slice(editedRanges.qualifierKey?.from, editedRanges.qualifierKey?.to)).toBe(
'hasTitle'
);
expect(
query.slice(editedRanges.qualifierOperator?.from, editedRanges.qualifierOperator?.to)
).toBe(':');
expect(query.slice(editedRanges.qualifierValue?.from, editedRanges.qualifierValue?.to)).toBe(
'"a"'
);
});

it('calculates the edited range when editing a string', () => {
expect(getEditedRanges('"hello"', 6)).toEqual({
from: 0,
to: 7
});
});

it('calculates the edited range when editing a group', () => {
expect(getEditedRanges('(hello world)', 9)).toEqual({
from: 0,
to: 13
});
});

it('calculates the edited range when editing a part after a qualifier', () => {
expect(getEditedRanges('hasTitle:"a" hello', 18)).toEqual({
from: 12,
to: 18
});
});

it('calculates the edited range when editing a part after a qualifier', () => {
expect(getEditedRanges('hasTitle:"a" hello', 18)).toEqual({
from: 12,
to: 18
});
});

it('calculates the edited range when editing a part before a qualifier', () => {
expect(getEditedRanges('hello hasTitle:"a"', 5)).toEqual({
from: 0,
to: 6
});
});

it('calculates the edited range when editing a part surrounded by qualifiers', () => {
expect(getEditedRanges('hasTitle:"a" hello hasTitle:"b', 18)).toEqual({
from: 12,
to: 19
});
});

it('calculates the edited range when editing a part after a group', () => {
expect(getEditedRanges('(hi) hello', 7)).toEqual({
from: 4,
to: 10
});
});

it('calculates the edited range when editing a part before a group', () => {
expect(getEditedRanges('hello (hi)', 3)).toEqual({
from: 0,
to: 6
});
});

it('calculates the edited range when editing a part surrounded by groups', () => {
expect(getEditedRanges('(hi) hello (hej)', 7)).toEqual({
from: 4,
to: 11
});
});

it('calculates the edited range when editing a part before a boolean operator', () => {
expect(getEditedRanges('hello AND world', 5)).toEqual({
from: 0,
to: 6
});
});

it('calculates the edited range when editing a part after a boolean operator', () => {
expect(getEditedRanges('hello AND world', 12)).toEqual({
from: 9,
to: 15
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { lxlQuery } from 'codemirror-lang-lxlquery';

type Range = {
from: number;
to: number;
};

export type EditedRanges = Range & {
qualifierKey?: Range;
qualifierOperator?: Range;
qualifierValue?: Range;
};

function getEditedRanges(query: string, cursor: number): EditedRanges {
const tree = lxlQuery.language.parser.parse(query);
const innerNode = tree.resolveInner(cursor);

/**
* Return `from` and `to` from qualifier parts if editing qualifier value
*/
if (innerNode.parent?.type.is('QualifierValue')) {
const qualifierNode = innerNode.parent.parent;
const qualifierKeyNode = qualifierNode?.getChild('QualifierKey');
const qualifierOperatorNode = qualifierNode?.getChild('QualifierOperator');
const qualiferValueNode = qualifierNode?.getChild('QualifierValue');
if (qualifierNode) {
return {
from: qualifierNode.from,
to: qualifierNode.to,
...(qualifierKeyNode && {
qualifierKey: { from: qualifierKeyNode.from, to: qualifierKeyNode.to }
}),
...(qualifierOperatorNode && {
qualifierOperator: { from: qualifierOperatorNode.from, to: qualifierOperatorNode.to }
}),
...(qualiferValueNode && {
qualifierValue: { from: qualiferValueNode.from, to: qualiferValueNode.to }
})
};
}
}

let from = 0;
let to = query.length;

/**
* Adjust `from` and `to` if enclosed qualifiers or groups are found BEFORE the edited part
* */
tree.iterate({
from: 0,
to: cursor,
enter(node) {
if (node.type.is('Qualifier') || node.type.is('Group') || node.type.is('BooleanOperator')) {
if (node.to > cursor) {
from = node.from;
to = node.to;
} else {
from = node.to;
}
}
}
});

/**
* Adjust `from` and `to` if enclosed qualifiers or groups are found AFTER the edited part
* */
tree.iterate({
from,
to,
enter(node) {
if (
(node.type.is('Qualifier') || node.type.is('Group') || node.type.is('BooleanOperator')) &&
node.from > cursor &&
node.from < to
) {
to = node.from;
}
}
});

return { from, to };
}

export default getEditedRanges;
8 changes: 4 additions & 4 deletions packages/codemirror-lang-lxlquery/src/syntax.grammar
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ term {
Group { "(" term* ")" }

BooleanQuery {
(freetext | Qualifier | Group ) booleanOperator (freetext | Qualifier | Group )
(booleanOperator (freetext | Qualifier | Group ))+?
(freetext | Qualifier | Group ) BooleanOperator (freetext | Qualifier | Group )
(BooleanOperator (freetext | Qualifier | Group ))+?
}

Qualifier {
Expand Down Expand Up @@ -47,15 +47,15 @@ freetext {

CompareOperator { ">" | "<" | ">=" | "<=" }

booleanOperator { "AND" | "OR" | "NOT" }
BooleanOperator { "AND" | "OR" | "NOT" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exporting BooleanOperator (oddly enough) breaks the highlighting of BooleanQuerys as it is implemented now. But of course we should do it.

My suggestion is when this and #1186 is merged, we properly re-add (the option to) highlighting operators within a valid BooleanQuery.


Wildcard { "*"+ }

reserved { "includeEplikt" | "includePreliminary" }

space { @whitespace+ }

@precedence { booleanOperator, reserved, Identifier }
@precedence { BooleanOperator, reserved, Identifier }
}

@detectDelim
Loading
Loading