diff --git a/lxl-web/src/lib/components/Search.svelte b/lxl-web/src/lib/components/Search.svelte index 5dc29463e..1d71cc7d8 100644 --- a/lxl-web/src/lib/components/Search.svelte +++ b/lxl-web/src/lib/components/Search.svelte @@ -5,19 +5,29 @@ import { SuperSearch, lxlQualifierPlugin } from 'supersearch'; import addDefaultSearchParams from '$lib/utils/addDefaultSearchParams'; import getSortedSearchParams from '$lib/utils/getSortedSearchParams'; + import getLabelFromMappings from '$lib/utils/getLabelsFromMapping.svelte'; + import type { DisplayMapping } from '$lib/types/search'; import BiSearch from '~icons/bi/search'; import { lxlQuery } from 'codemirror-lang-lxlquery'; import '$lib/styles/lxlquery.css'; - export let placeholder: string; - export let autofocus: boolean = false; + interface Props { + placeholder: string; + autofocus?: boolean; + } + + let { placeholder, autofocus = false }: Props = $props(); + + const useSuperSearch = env?.PUBLIC_USE_SUPERSEARCH === 'true'; + const showAdvanced = $page.url.searchParams.get('_x') === 'advanced' || useSuperSearch; - $: showAdvanced = $page.url.searchParams.get('_x') === 'advanced'; - let q = $page.params.fnurgel - ? '' //don't reflect related search on resource pages - : showAdvanced - ? $page.url.searchParams.get('_q')?.trim() || '' - : $page.url.searchParams.get('_i')?.trim() || ''; + let q = $state( + $page.params.fnurgel + ? '' //don't reflect related search on resource pages + : showAdvanced + ? $page.url.searchParams.get('_q')?.trim() || '' + : $page.url.searchParams.get('_i')?.trim() || '' + ); let params = getSortedSearchParams(addDefaultSearchParams($page.url.searchParams)); // Always reset these params on new search @@ -27,10 +37,12 @@ params.delete('_p'); const searchParams = Array.from(params); + let suggestMapping: DisplayMapping[] | undefined = $state(); + afterNavigate(({ to }) => { /** Update input value after navigation on /find route */ if (to?.url) { - let param = showAdvanced ? '_q' : '_i'; + let param = $page.url.searchParams.get('_x') === 'advanced' || useSuperSearch ? '_q' : '_i'; q = $page.params.fnurgel ? '' : new URL(to.url).searchParams.get(param)?.trim() || ''; } }); @@ -54,10 +66,23 @@ } return undefined; } + + function handleTransform(data) { + suggestMapping = data?.mapping; + return data; + } + + let derivedLxlQualifierPlugin = $derived.by(() => { + function getLabels(key: string, value?: string) { + let pageMapping = $page.data.searchResult?.mapping; + return getLabelFromMappings(key, value, pageMapping, suggestMapping); + } + return lxlQualifierPlugin(getLabels); + }); -
- {#if env?.PUBLIC_USE_SUPERSEARCH === 'true'} + + {#if useSuperSearch} {#snippet resultItem(item)} + + {#if $page.url.searchParams.get('_x') === 'advanced'} + + + {/if} {/if} + + {#each searchParams as [name, value]} + {#if name !== '_q'} + + {/if} + {/each} diff --git a/lxl-web/src/lib/styles/lxlquery.css b/lxl-web/src/lib/styles/lxlquery.css index 63c411d15..f8f57f0cc 100644 --- a/lxl-web/src/lib/styles/lxlquery.css +++ b/lxl-web/src/lib/styles/lxlquery.css @@ -1,17 +1,47 @@ -.lxl-qualifier, -.lxl-qualifier-remove { - background: rgba(14, 113, 128, 0.15); - padding: 2px 5px; +.lxl-qualifier { + display: inline-flex; + color: rgb(0, 128, 0); + background: rgba(14, 113, 128, 0.1); + padding-top: 3px; + padding-bottom: 3px; } .lxl-qualifier-key { - color: green; + padding-left: 5px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.lxl-qualifier-value, +.lxl-qualifier-remove { + padding-right: 5px; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.lxl-qualifier-remove, +.lxl-qualifier-value.atomic { + padding-left: 5px; +} + +.lxl-qualifier-value:has(~ .lxl-qualifier-remove) { + border-radius: 0; +} + +.invalid > .lxl-qualifier-key { + text-decoration: underline 2px solid; + text-decoration-color: rgba(255, 0, 0, 0.326); +} + +.atomic { + background: rgba(14, 113, 128, 0.2); + user-select: none; } .lxl-boolean-query { - color: purple; + color: rgb(128, 0, 128); } .lxl-wildcard { - color: purple; + color: rgb(128, 0, 128); } diff --git a/lxl-web/src/lib/types/search.ts b/lxl-web/src/lib/types/search.ts index 13a82d877..1aeec90df 100644 --- a/lxl-web/src/lib/types/search.ts +++ b/lxl-web/src/lib/types/search.ts @@ -68,10 +68,12 @@ interface SpellingSuggestion { export interface DisplayMapping { '@id'?: string; display?: DisplayDecorated; + displayStr?: string; up?: Link; children?: DisplayMapping[]; label?: string; operator: keyof typeof SearchOperators; + property?: string; } export interface PartialCollectionView { diff --git a/lxl-web/src/lib/utils/getLabelsFromMapping.svelte.ts b/lxl-web/src/lib/utils/getLabelsFromMapping.svelte.ts new file mode 100644 index 000000000..d4d9f4d85 --- /dev/null +++ b/lxl-web/src/lib/utils/getLabelsFromMapping.svelte.ts @@ -0,0 +1,62 @@ +import type { DisplayMapping } from '$lib/types/search'; + +let prevSuggestMapping: DisplayMapping[] | undefined; + +function getLabelFromMappings( + key: string, + value?: string, + pageMapping?: DisplayMapping[], + suggestMapping?: DisplayMapping[] +) { + const pageLabels = iterateMapping(key, value, pageMapping); + const suggestLabels = iterateMapping(key, value, suggestMapping || prevSuggestMapping); + + const keyLabel = suggestLabels.keyLabel || pageLabels.keyLabel; + const valueLabel = suggestLabels.valueLabel || pageLabels.valueLabel; + // only page data have 'up' links we can use + const removeLink = pageLabels.keyLabel ? pageLabels.removeLink : undefined; + + if (suggestMapping) { + // TODO remove when invalid qualifier no longer result in empty error response + // until when we need to save latest 'successful' suggest mapping + prevSuggestMapping = suggestMapping; + } + + return { keyLabel, valueLabel, removeLink }; +} + +function iterateMapping( + key: string, + value: string | undefined, + mapping: DisplayMapping[] | undefined | null +) { + let keyLabel: string | undefined; + let valueLabel: string | undefined; + let removeLink: string | undefined; + + if (mapping && Array.isArray(mapping)) { + _iterate(mapping); + + function _iterate(m: DisplayMapping[]) { + m.forEach((el) => { + if (el.children) { + _iterate(el.children); + } else if (el.property === key) { + keyLabel = el.label; + const isLinked = !!el.display?.['@id']; + if (isLinked) { + if (el.displayStr) { + // only use atomic ranges for linked values + valueLabel = el.displayStr; + } + // only show remove btn for pills that can't be edited + removeLink = el.up?.['@id']; + } + } + }); + } + } + return { keyLabel, valueLabel, removeLink }; +} + +export default getLabelFromMappings; diff --git a/lxl-web/src/lib/utils/getLabelsFromMapping.test.ts b/lxl-web/src/lib/utils/getLabelsFromMapping.test.ts new file mode 100644 index 000000000..6f04d6929 --- /dev/null +++ b/lxl-web/src/lib/utils/getLabelsFromMapping.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import getLabelsFromMapping from './getLabelsFromMapping.svelte'; +import type { DisplayMapping } from '$lib/types/search'; + +describe('getLabelsFromMapping', () => { + it('it returns labels when supplied with page mappings', () => { + const labels = getLabelsFromMapping('genreForm', 'saogf:Romaner', pageMapping, undefined); + expect(labels.keyLabel).toBe('Genre/form'); + expect(labels.valueLabel).toBe('Romaner'); + }); + + it('it returns labels when supplied with suggest mappings', () => { + const labels = getLabelsFromMapping('genreForm', 'saogf:Romaner', undefined, suggestMapping); + expect(labels.keyLabel).toBe('Genre/form'); + expect(labels.valueLabel).toBe('Romaner'); + }); + + it('it returns suggest labels when supplied with both mappings', () => { + const labels = getLabelsFromMapping('SPRÅK', 'lang:swe', pageMapping, suggestMapping); + expect(labels.keyLabel).toBe('Språk'); + expect(labels.valueLabel).toBe('Svenska'); + }); + + it('it returns undefined values when no correct key match', () => { + const labels = getLabelsFromMapping('invalid', 'saogf:Romaner', pageMapping, suggestMapping); + expect(labels.keyLabel).toBe(undefined); + expect(labels.valueLabel).toBe(undefined); + }); + + // TODO run test when value matching is done properly + // it('it returns no value label when value does not match', () => { + // const labels = getLabelsFromMapping('genreForm', 'saogf:SomeInvalidTerm', pageMapping, null); + // expect(labels.keyLabel).toBe('Genre/form'); + // expect(labels.valueLabel).toBe(undefined); + // }); + + it('does not return value labels for unlinked items', () => { + const labels = getLabelsFromMapping('ÅR', '2023', pageMapping, suggestMapping); + expect(labels.keyLabel).toBe('Utgivningsår'); + expect(labels.valueLabel).toBe(undefined); + }); + + it('does not return a removelink for unlinked items', () => { + const labels = getLabelsFromMapping('ÅR', '2023', pageMapping, suggestMapping); + expect(labels.removeLink).toBe(undefined); + }); + + it('does not return a removelink when using suggest mappings', () => { + const labels = getLabelsFromMapping('genreForm', 'saogf:Romaner', undefined, suggestMapping); + expect(labels.removeLink).toBe(undefined); + }); + + it('returns a removelink when using page mappings', () => { + const labels = getLabelsFromMapping('genreForm', 'saogf:Romaner', pageMapping, undefined); + expect(labels.removeLink).toBe('/find?_i=sommar&_q=sommar+%C3%85R:2023&_limit=20&_spell=true'); + }); +}); + +// sommar genreForm:"saogf:Romaner" ÅR:2023 +const pageMapping: DisplayMapping[] = [ + { + children: [ + { + '@id': 'https://id.kb.se/vocab/textQuery', + display: 'sommar', + displayStr: 'sommar', + label: 'Fritextsökning', + property: 'textQuery', + operator: 'equals', + up: { + '@id': '/find?_i=&_q=genreForm:%22saogf:Romaner%22+%C3%85R:2023&_limit=20&_spell=true' + } + }, + { + '@id': 'https://id.kb.se/vocab/genreForm', + display: { + '@id': 'https://id.kb.se/term/saogf/Romaner', + '@type': 'GenreForm', + _display: [ + { + prefLabel: 'Romaner', + _label: 'föredragen benämning' + } + ], + _style: ['link', 'pill'], + _label: 'Genre/form' + }, + displayStr: 'Romaner', + label: 'Genre/form', + property: 'genreForm', + operator: 'equals', + up: { + '@id': '/find?_i=sommar&_q=sommar+%C3%85R:2023&_limit=20&_spell=true' + } + }, + { + '@id': 'https://id.kb.se/vocab/yearPublished', + display: '2023', + displayStr: '2023', + label: 'Utgivningsår', + property: 'ÅR', + operator: 'equals', + up: { + '@id': '/find?_i=sommar&_q=sommar+genreForm:%22saogf:Romaner%22&_limit=20&_spell=true' + } + } + ], + operator: 'and', + up: { + '@id': '/find?_i=&_q=*&_limit=20&_spell=true' + } + } +]; + +// sommar genreForm:"saogf:Romaner" ÅR:2023 SPRÅK:"lang:swe" +const suggestMapping: DisplayMapping[] = [ + { + children: [ + { + '@id': 'https://id.kb.se/vocab/textQuery', + display: 'sommar', + displayStr: 'sommar', + label: 'Fritextsökning', + property: 'textQuery', + operator: 'equals', + up: { + '@id': + '/find?_i=&_q=genreForm:%22saogf:Romaner%22+%C3%85R:2023+SPR%C3%85K:%22lang:swe%22&_limit=10' + } + }, + { + '@id': 'https://id.kb.se/vocab/genreForm', + display: { + '@id': 'https://id.kb.se/term/saogf/Romaner', + '@type': 'GenreForm', + _display: [ + { + prefLabel: 'Romaner', + _label: 'föredragen benämning' + } + ], + _style: ['link', 'pill'], + _label: 'Genre/form' + }, + displayStr: 'Romaner', + label: 'Genre/form', + property: 'genreForm', + operator: 'equals', + up: { + '@id': '/find?_i=sommar&_q=sommar+%C3%85R:2023+SPR%C3%85K:%22lang:swe%22&_limit=10' + } + }, + { + '@id': 'https://id.kb.se/vocab/yearPublished', + display: '2023', + displayStr: '2023', + label: 'Utgivningsår', + property: 'ÅR', + operator: 'equals', + up: { + '@id': + '/find?_i=sommar&_q=sommar+genreForm:%22saogf:Romaner%22+SPR%C3%85K:%22lang:swe%22&_limit=10' + } + }, + { + '@id': 'https://id.kb.se/vocab/language', + display: { + '@id': 'https://id.kb.se/language/swe', + '@type': 'Language', + _display: [ + { + prefLabel: 'Svenska', + _label: 'föredragen benämning' + } + ], + _label: 'Språk' + }, + displayStr: 'Svenska', + label: 'Språk', + property: 'SPRÅK', + operator: 'equals', + up: { + '@id': '/find?_i=sommar&_q=sommar+genreForm:%22saogf:Romaner%22+%C3%85R:2023&_limit=10' + } + } + ], + operator: 'and', + up: { + '@id': '/find?_i=&_q=*&_limit=10' + } + } +]; diff --git a/lxl-web/src/lib/utils/search.ts b/lxl-web/src/lib/utils/search.ts index a31fa70ef..2925ba60e 100644 --- a/lxl-web/src/lib/utils/search.ts +++ b/lxl-web/src/lib/utils/search.ts @@ -75,12 +75,12 @@ export async function asResult( }; } -function displayMappings( +export function displayMappings( view: PartialCollectionView, displayUtil: DisplayUtil, locale: LangCode, translate: translateFn, - usePath: string + usePath?: string ): DisplayMapping[] { const mapping = view.search?.mapping || []; return _iterateMapping(mapping); @@ -94,11 +94,16 @@ function displayMappings( return { ...(isObject(m.property) && { '@id': m.property['@id'] }), display: displayUtil.lensAndFormat(property, LensType.Chip, locale), + displayStr: toString(displayUtil.lensAndFormat(property, LensType.Chip, locale)) || '', label: m.alias ? translate(`facet.${m.alias}`) : capitalize(m.property?.labelByLang?.[locale] || m.property?.label) || m.property?.['@id'] || 'No label', // lensandformat? + property: + m.property?.librisQueryCode || + m.property?.['@id'].replace('https://id.kb.se/vocab/', '') || + '', //TODO replace with something better operator, ...('up' in m && { up: replacePath(m.up as Link, usePath) }) } as DisplayMapping; @@ -238,10 +243,13 @@ function displayBoolFilters( /** * prevent links on resource page from pointing to /find */ -function replacePath(view: Link, usePath: string) { - return { - '@id': view['@id'].replace('/find', usePath) - }; +function replacePath(view: Link, usePath: string | undefined) { + if (usePath) { + return { + '@id': view['@id'].replace('/find', usePath) + }; + } + return view; } function capitalize(str: string | undefined) { diff --git a/lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts b/lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts index 2a156b87f..f40b6ea9e 100644 --- a/lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts +++ b/lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts @@ -4,6 +4,8 @@ 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 { displayMappings } from '$lib/utils/search.js'; +import { getTranslator } from '$lib/i18n/index.js'; import getEditedPartEntries from './getEditedPartEntries.js'; /** @@ -15,6 +17,7 @@ import getEditedPartEntries from './getEditedPartEntries.js'; export const GET: RequestHandler = async ({ url, params, locals }) => { const displayUtil = locals.display; const locale = getSupportedLocale(params?.lang); + const translate = await getTranslator(locale); const _q = url.searchParams.get('_q'); const cursor = parseInt(url.searchParams.get('cursor') || '0', 10); @@ -42,6 +45,9 @@ export const GET: RequestHandler = async ({ url, params, locals }) => { '@type': item['@type'], heading: toString(displayUtil.lensAndFormat(item, LxlLens.CardHeading, locale)) })), + ...(data?.search?.mapping && { + mapping: displayMappings(data, displayUtil, locale, translate) + }), totalItems: data.totalItems, '@context': data['@context'] }); diff --git a/lxl-web/tests/index.spec.ts b/lxl-web/tests/index.spec.ts index e0299be07..a821fa6b0 100644 --- a/lxl-web/tests/index.spec.ts +++ b/lxl-web/tests/index.spec.ts @@ -35,6 +35,6 @@ test('url is populated with correct searchparams', async ({ page }) => { await page.getByTestId('main-search').fill('somephrase'); await page.getByTestId('main-search').press('Enter'); await expect(page).toHaveURL( - /_q=somephrase&_limit=20&_offset=0&_sort=&_spell=true&_i=somephrase/ + /_q=somephrase&_i=somephrase&_limit=20&_offset=0&_sort=&_spell=true/ ); }); diff --git a/packages/codemirror-lang-lxlquery/src/index.ts b/packages/codemirror-lang-lxlquery/src/index.ts index ec129bda0..1312975b3 100644 --- a/packages/codemirror-lang-lxlquery/src/index.ts +++ b/packages/codemirror-lang-lxlquery/src/index.ts @@ -2,24 +2,44 @@ import { parser } from './syntax.grammar'; import { LRLanguage, LanguageSupport, syntaxHighlighting } from '@codemirror/language'; import { styleTags, Tag, tagHighlighter } from '@lezer/highlight'; -// custom tags attached to the language parser -const customTags = { +/** + * Custom tags attached to the language parser + * see https://lezer.codemirror.net/docs/ref/#highlight.styleTags + * for the matching syntax + */ +const tags = { BooleanQuery: Tag.define('BooleanQuery'), - Wildcard: Tag.define('Wildcard') + Wildcard: Tag.define('Wildcard'), + Qualifier: Tag.define('Qualifier'), + QualifierKey: Tag.define('QualifierKey'), + QualifierOperator: Tag.define('QualifierOperator'), + QualifierValue: Tag.define('QualifierValue') +}; + +const tagMatcher = { + BooleanQuery: tags.BooleanQuery, + Wildcard: tags.Wildcard, + 'Qualifier/...': tags.Qualifier, + 'QualifierKey/...': tags.QualifierKey, + 'QualifierOperator/...': tags.QualifierOperator, + 'QualifierValue/...': tags.QualifierValue }; export const lxlQueryLanguage = LRLanguage.define({ name: 'Libris XL query', parser: parser.configure({ - props: [styleTags(customTags)] + props: [styleTags(tagMatcher)] }), languageData: {} }); const highlighter = tagHighlighter([ - // adding qualifier classes handled by sypersearch/lxlQualifier plugin - { tag: customTags.BooleanQuery, class: 'lxl-boolean-query' }, - { tag: customTags.Wildcard, class: 'lxl-wildcard' } + { tag: tags.BooleanQuery, class: 'lxl-boolean-query' }, + { tag: tags.Wildcard, class: 'lxl-wildcard' }, + { tag: tags.Qualifier, class: 'lxl-qualifier' }, + { tag: tags.QualifierKey, class: 'lxl-qualifier-key' }, + { tag: tags.QualifierOperator, class: 'lxl-qualifier-operator' }, + { tag: tags.QualifierValue, class: 'lxl-qualifier-value' } ]); const highlighterExtension = syntaxHighlighting(highlighter); diff --git a/packages/supersearch/README.md b/packages/supersearch/README.md index c73225f42..4a5e81ea1 100644 --- a/packages/supersearch/README.md +++ b/packages/supersearch/README.md @@ -31,6 +31,12 @@ To use `supersearch` in a non-Svelte project ... | `resultItem` | `Snippet<[ResultItem]>` | A [Snippet](https://svelte.dev/docs/svelte/snippet) used for customized rendering of result items. | `undefined` | | `debouncedWait` | `number` | The wait time, in milliseconds that debounce function should wait between invocated search queries. | `300` | +  +Supersearch also exports a `lxlQualifierPlugin` that can be used (passed to the extensions prop) if you want atomic, stylable, removable, labeled pills from some key-value pairs in your editor. This requires: + +- Your language exporting `Qualifier` nodes consisting of `QualifierKey`, `QualifierOperator` and `QualifierValue` (i.e `key:value`). +- Your pass a function of type `GetLabelFunction`, returning labels to be displayed and an optional remove link. + ## Developing Install dependencies with `npm install` and start a development server: diff --git a/packages/supersearch/src/lib/components/CodeMirror.svelte b/packages/supersearch/src/lib/components/CodeMirror.svelte index abeff226e..5d15030e7 100644 --- a/packages/supersearch/src/lib/components/CodeMirror.svelte +++ b/packages/supersearch/src/lib/components/CodeMirror.svelte @@ -37,14 +37,13 @@ selection: update.state.selection, scrollIntoView: update.transactions?.[0].scrollIntoView }); - } - - if (update.docChanged) { - value = update.state.doc.toString(); - onchange({ - value, - cursor: update.state.selection.main.anchor - }); + if (update.docChanged) { + value = update.state.doc.toString(); + onchange({ + value, + cursor: update.state.selection.main.anchor + }); + } } }); @@ -95,6 +94,13 @@ }); }); + $effect(() => { + if (value !== editorView?.state.doc.toString()) { + // Reset editor when value changes from outside (= user navigating) + reset({ doc: value }); + } + }); + $effect(() => { if (extensions !== prevExtensions) { reconfigureAllExtensions(); diff --git a/packages/supersearch/src/lib/components/SuperSearch.svelte b/packages/supersearch/src/lib/components/SuperSearch.svelte index 9a57726e9..257204613 100644 --- a/packages/supersearch/src/lib/components/SuperSearch.svelte +++ b/packages/supersearch/src/lib/components/SuperSearch.svelte @@ -2,11 +2,12 @@ import { onMount, onDestroy, type Snippet } from 'svelte'; import CodeMirror, { type ChangeCodeMirrorEvent } from '$lib/components/CodeMirror.svelte'; import { EditorView, placeholder as placeholderExtension, keymap } from '@codemirror/view'; - import { Compartment, type Extension } from '@codemirror/state'; + import { Compartment, StateEffect, type Extension } from '@codemirror/state'; import { type LanguageSupport } from '@codemirror/language'; import submitFormOnEnterKey from '$lib/extensions/submitFormOnEnterKey.js'; import preventNewLine from '$lib/extensions/preventNewLine.js'; import useSearchRequest from '$lib/utils/useSearchRequest.svelte.js'; + import { messages } from '$lib/constants/messages.js'; import type { QueryFunction, PaginationQueryFunction, @@ -58,6 +59,18 @@ transformFn }); + let prevSearchDataId: string | undefined; + const sendMessage = StateEffect.define<{ message: string }>({}); + const newDataMessage = { effects: sendMessage.of({ message: messages.NEW_DATA }) }; + + $effect(() => { + if (search.data && search.data?.['@id'] !== prevSearchDataId) { + expandedEditorView?.dispatch(newDataMessage); + collapsedEditorView?.dispatch(newDataMessage); + prevSearchDataId = search.data?.['@id']; + } + }); + $effect(() => { if (value && value.trim()) { search.debouncedFetchData(value, cursor); @@ -78,7 +91,7 @@ } function handleChangeCodeMirror(event: ChangeCodeMirrorEvent) { - if (!dialog?.open) { + if (!dialog?.open && value !== event.value) { showExpandedSearch(); } value = event.value; diff --git a/packages/supersearch/src/lib/constants/messages.ts b/packages/supersearch/src/lib/constants/messages.ts new file mode 100644 index 000000000..49366458f --- /dev/null +++ b/packages/supersearch/src/lib/constants/messages.ts @@ -0,0 +1,3 @@ +export const messages = { + NEW_DATA: 'new_response_data' +}; diff --git a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierComponent.svelte b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierComponent.svelte new file mode 100644 index 000000000..4a5d0813c --- /dev/null +++ b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierComponent.svelte @@ -0,0 +1,31 @@ + + + + {keyLabel} + + + {operator} + +{#if valueLabel} + + {valueLabel} + +{/if} +{#if valueLabel && removeLink} + + X + +{/if} diff --git a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierKey.svelte b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierKey.svelte deleted file mode 100644 index 702ae8233..000000000 --- a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierKey.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - {label || key}{operator} - - - diff --git a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierRemove.svelte b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierRemove.svelte deleted file mode 100644 index f8fc5d39b..000000000 --- a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/QualifierRemove.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - X - - diff --git a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/index.ts b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/index.ts index 7433e89ed..25339e9e6 100644 --- a/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/index.ts +++ b/packages/supersearch/src/lib/extensions/lxlQualifierPlugin/index.ts @@ -9,9 +9,9 @@ import { import { EditorState, type Range } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { mount } from 'svelte'; -import QualifierRemove from './QualifierRemove.svelte'; -import QualifierKey from './QualifierKey.svelte'; +import QualifierComponent from './QualifierComponent.svelte'; import insertQuotes from './insertQuotes.js'; +import { messages } from '$lib/constants/messages.js'; export type Qualifier = { key: string; @@ -19,55 +19,51 @@ export type Qualifier = { operator: string; }; -class RemoveWidget extends WidgetType { - constructor( - readonly range: { from: number; to: number }, - readonly atomic: boolean - ) { - super(); - } - eq(other: RemoveWidget) { - return this.range.from === other.range.from && this.range.to === other.range.to; - } - // is there a way to pass new props to component instead of re-mounting every time range changes? - toDOM(): HTMLElement { - const container = document.createElement('span'); - container.style.cssText = `position: relative;`; - mount(QualifierRemove, { - props: { - range: this.range, - url: new URL(window?.location.href) - }, - target: container - }); - return container; - } -} +export type GetLabelFunction = ( + key: string, + value?: string +) => { + keyLabel?: string; + valueLabel?: string; + removeLink?: string; +}; -class QualifierKeyWidget extends WidgetType { +class QualifierWidget extends WidgetType { constructor( readonly key: string, - readonly operator: string, - readonly label: string | undefined, + readonly keyLabel: string | undefined, readonly keyType: string | undefined, + readonly value: string | undefined, + readonly valueLabel: string | undefined, + readonly operator: string, readonly operatorType: string | undefined, + readonly removeLink: string | undefined, readonly atomic: boolean ) { super(); } - eq(other: QualifierKeyWidget): boolean { - return this.key === other.key && this.operator === other.operator; + eq(other: QualifierWidget): boolean { + return ( + this.key === other.key && + this.keyLabel === other.keyLabel && + this.operator === other.operator && + this.value === other.value && + this.valueLabel === other.valueLabel + ); } toDOM(): HTMLElement { const container = document.createElement('span'); - container.style.cssText = `position: relative;`; - mount(QualifierKey, { + container.style.cssText = `position: relative; display:inline-flex`; + mount(QualifierComponent, { props: { key: this.key, - operator: this.operator, - label: this.label, + keyLabel: this.keyLabel, keyType: this.keyType, - operatorType: this.operatorType + value: this.value, + valueLabel: this.valueLabel, + operator: this.operator, + operatorType: this.operatorType, + removeLink: this.removeLink }, target: container }); @@ -75,87 +71,111 @@ class QualifierKeyWidget extends WidgetType { } } -function getQualifiers(view: EditorView) { - const widgets: Range[] = []; - const doc = view.state.doc.toString(); +function lxlQualifierPlugin(getLabelFn?: GetLabelFunction) { + function getQualifiers(view: EditorView) { + const widgets: Range[] = []; + const doc = view.state.doc.toString(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name === 'Qualifier') { + const keyNode = node.node.getChild('QualifierKey'); + const key = keyNode ? doc.slice(keyNode?.from, keyNode?.to) : ''; + const keyType = keyNode?.firstChild?.type.name; + + const operatorNode = node.node.getChild('QualifierOperator'); + const operator = operatorNode ? doc.slice(operatorNode?.from, operatorNode?.to) : ''; + const operatorType = operatorNode?.firstChild?.type.name; - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - enter: (node) => { - if (node.name === 'Qualifier') { - // Mark decoration to create wrapper element, non-atomic - const qualifierMark = Decoration.mark({ - class: 'lxl-qualifier', - inclusive: true, - atomic: false // invented! - }); - widgets.push(qualifierMark.range(node.from, node.to)); + const valueNode = node.node.getChild('QualifierValue'); + const value = valueNode ? doc.slice(valueNode?.from, valueNode?.to) : undefined; - // Remove decoration (x-button) widget - atomic - const removeDecoration = Decoration.widget({ - widget: new RemoveWidget({ from: node.from, to: node.to }, true), - side: 1 - }); - widgets.push(removeDecoration.range(node.to)); + const { keyLabel, valueLabel, removeLink } = getLabelFn?.(key, value) || {}; - // Qualifier key + operator widget - atomic - const keyNode = node.node.getChild('QualifierKey'); - const operatorNode = node.node.getChild('QualifierOperator'); + // Add qualifier widget + if (keyLabel) { + const qualifierDecoration = Decoration.replace({ + widget: new QualifierWidget( + key, + keyLabel, + keyType, + value, + valueLabel, + operator, + operatorType, + removeLink, + true // atomic + ) + }); + const decorationRangeFrom = node.from; + const decorationRangeTo = valueLabel ? node.to : operatorNode?.to; + widgets.push(qualifierDecoration.range(decorationRangeFrom, decorationRangeTo)); + } else { + // Add invalid key mark decoration + const qualifierMark = Decoration.mark({ + class: 'invalid', + inclusive: true, + atomic: false + }); + const invalidRangeFrom = keyNode ? keyNode.from : node.from; + const invalidRangeTo = keyNode ? keyNode.to : operatorNode?.from; - if (keyNode && operatorNode) { - const keyDecoration = Decoration.replace({ - widget: new QualifierKeyWidget( - doc.slice(keyNode?.from, keyNode?.to), - doc.slice(operatorNode?.from, operatorNode?.to), - doc.slice(keyNode?.from, keyNode?.to), // label should be found using vocab - keyNode.firstChild?.type.name, - operatorNode.firstChild?.type.name, - true - ) - }); - widgets.push(keyDecoration.range(keyNode?.from, operatorNode?.to)); + widgets.push(qualifierMark.range(invalidRangeFrom, invalidRangeTo)); + } } } - } - }); + }); + } + return Decoration.set(widgets, true); // true = sort } - return Decoration.set(widgets, true); // true = sort -} + /** + * filter out non-atomics using custom property 'atomic' + */ + const filterAtomic = (from: number, to: number, decoration: Decoration) => { + return decoration.spec?.atomic || decoration.spec?.widget?.atomic; + }; -/** - * filter out non-atomics using custom property 'atomic' - */ -const filterAtomic = (from: number, to: number, decoration: Decoration) => { - return decoration.spec?.atomic || decoration.spec?.widget?.atomic; -}; - -export const lxlQualifierPlugin = ViewPlugin.fromClass( - class { - qualifiers: DecorationSet; - constructor(view: EditorView) { - this.qualifiers = getQualifiers(view); - } + const qualifierPlugin = ViewPlugin.fromClass( + class { + qualifiers: DecorationSet; + constructor(view: EditorView) { + this.qualifiers = getQualifiers(view); + } - update(update: ViewUpdate) { - if (update.docChanged || syntaxTree(update.startState) != syntaxTree(update.state)) { - this.qualifiers = getQualifiers(update.view); + update(update: ViewUpdate) { + if (update.docChanged || syntaxTree(update.startState) != syntaxTree(update.state)) { + // TODO: Calling getQualifiers on every document change is probably not good for performance + // Try optimizing; either run the function only on certain kinds of input, or split getQualifiers; + // one that updates the widgets (on input) and one that looks for labels (on data update) + this.qualifiers = getQualifiers(update.view); + } else { + for (const tr of update.transactions) { + for (const e of tr.effects) { + if (e.value.message === messages.NEW_DATA) { + this.qualifiers = getQualifiers(update.view); + } + } + } + } } + }, + { + decorations: (instance) => instance.qualifiers, + eventHandlers: {}, + provide: (plugin) => [ + EditorView.atomicRanges.of((view) => { + const filteredRanges = view.plugin(plugin)?.qualifiers.update({ filter: filterAtomic }); + return filteredRanges || Decoration.none; + }), + EditorState.transactionFilter.of(insertQuotes) + ] } - }, - { - decorations: (instance) => instance.qualifiers, - eventHandlers: {}, - provide: (plugin) => [ - EditorView.atomicRanges.of((view) => { - const filteredRanges = view.plugin(plugin)?.qualifiers.update({ filter: filterAtomic }); - return filteredRanges || Decoration.none; - }), - EditorState.transactionFilter.of(insertQuotes) - ] - } -); + ); + return qualifierPlugin; +} export default lxlQualifierPlugin; diff --git a/packages/supersearch/src/routes/+page.svelte b/packages/supersearch/src/routes/+page.svelte index 62437b6c0..46e6f4b87 100644 --- a/packages/supersearch/src/routes/+page.svelte +++ b/packages/supersearch/src/routes/+page.svelte @@ -48,7 +48,7 @@ paginationQueryFn={handlePaginationQuery} transformFn={handleTransform} language={lxlQuery} - extensions={[lxlQualifierPlugin]} + extensions={[lxlQualifierPlugin()]} > {#snippet resultItem(item)}