diff --git a/.changeset/iri-hover-info.md b/.changeset/iri-hover-info.md new file mode 100644 index 00000000..bab448f9 --- /dev/null +++ b/.changeset/iri-hover-info.md @@ -0,0 +1,10 @@ +--- +"@matdata/yasgui": minor +"@matdata/yasqe": minor +--- + +Show the label, description and type of an IRI when hovering it in the query editor (#196). + +After a short delay a tooltip shows the full IRI, its `rdfs:label` / `skos:prefLabel` (in the browser language when available), `rdfs:comment` / `skos:definition`, `rdf:type` and, for properties, `rdfs:domain` / `rdfs:range`. The information is fetched with a small background query on the tab's endpoint (with its authentication), cached per endpoint and IRI, and looked up in LOV when the endpoint doesn't describe the IRI. Users can switch it off in Settings → Editor; embedding pages can use the new Yasgui options `iriHover` and `iriHoverLovFallback`. + +YASQE gets an `iriHover` option (tooltip content for a hovered IRI), `iriHoverDelay`, and a `getIriAt(pos)` method that returns the IRI or expanded prefixed name at a position. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 356c2a67..aba8cf6a 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -708,6 +708,13 @@ interface Config { // in the Insert menu of the editor and in the Ctrl+K search showSnippetsBar?: boolean; // default: false + // Tooltip with the label, description and type of an IRI hovered in the editor, + // fetched from the tab's endpoint. Users can also switch it off in Settings → Editor + iriHover?: boolean; // default: true + + // Look up IRIs the endpoint doesn't describe in LOV (lov.linkeddata.es) + iriHoverLovFallback?: boolean; // default: true + // Page opened by the help button of the top bar; false hides the button helpUrl?: string | false; // default: 'https://matgui-doc.matdata.eu/docs/user-guide' } @@ -875,6 +882,11 @@ interface YasqeConfig { // Code snippets snippets: Snippet[]; // default: 5 SPARQL snippets showSnippetsBar: boolean; // default: true (standalone Yasqe) + + // Tooltip content for an IRI or prefixed name hovered in the editor (Yasgui sets this to + // show its label, description and type). Return nothing to show no tooltip. + iriHover: ((yasqe: Yasqe, iri: IriAtPosition) => HTMLElement | null | undefined) | undefined; + iriHoverDelay: number; // default: 500 (ms) } ``` diff --git a/docs/user-guide.md b/docs/user-guide.md index ec7a7cb4..9b7aa910 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,6 +44,7 @@ - [Endpoint Quick Switch](#endpoint-quick-switch) - [Configuration Import/Export](#configuration-importexport) - [URI Explorer](#uri-explorer) + - [IRI Hover Info](#iri-hover-info) - [Endpoint Overview](#endpoint-overview) - [Query Tabs](#query-tabs) - [Settings Modal](#settings-modal) @@ -849,6 +850,21 @@ Alternatively, use `Ctrl`+`Shift` and click on the URI to executes a CONSTRUCT q **Example:** Ctrl+clicking on `http://dbpedia.org/resource/European_Union` automatically queries for all triples where the European Union is the subject of. +### IRI Hover Info + +Rest the mouse on an IRI or prefixed name in the query editor (for example `era:solNature`) to see what it means, without running anything or losing your results. After a short moment a tooltip shows: + +- the label (`rdfs:label` or `skos:prefLabel`), in your browser's language when the endpoint has it, otherwise in English, +- the full IRI, when you hovered a prefixed name, +- the description (`rdfs:comment` or `skos:definition`), shortened when it is long, +- the type, and for properties the domain and range, shortened with the prefixes of your query. + +The information is fetched with a small query on the endpoint of the tab, using its authentication and request settings, and is remembered per endpoint and IRI, so hovering the same IRI again is instant. When the endpoint doesn't describe the IRI, MatGUI looks it up in [LOV (Linked Open Vocabularies)](https://lov.linkeddata.es), which knows most published vocabularies; the tooltip then says "Description from LOV". + +Prefixed names whose prefix isn't declared in the query get no tooltip. The tooltip also reminds you that Ctrl+click and Ctrl+Shift+click open the [URI Explorer](#uri-explorer). + +To switch it off, untick **Show IRI information on hover** in **Settings → Editor**. + ### Endpoint Overview Get to know an unfamiliar SPARQL endpoint while you write your queries. Click the compass in the bar of icons at the left, press `F8`, or pick **Explore endpoint** in the [search](#search) to open the **Endpoint** view of the [side panel](#side-panel), next to the editor. @@ -970,6 +986,7 @@ Access comprehensive configuration options through the Settings modal. - Select CodeMirror theme for dark mode - Switch between the Default and GitHub Dark editor themes in the Settings → Editor tab - Includes custom "GitHub Dark" theme matching GitHub's dark default colors +- Show IRI information on hover (see [IRI Hover Info](#iri-hover-info)) All settings are saved automatically to local storage. diff --git a/packages/yasgui/src/IriHoverInfo.scss b/packages/yasgui/src/IriHoverInfo.scss new file mode 100644 index 00000000..0be3a533 --- /dev/null +++ b/packages/yasgui/src/IriHoverInfo.scss @@ -0,0 +1,78 @@ +// Tooltip shown when hovering an IRI in the query editor (see IriHoverInfo.ts). +// CodeMirror puts the `.yasqe_iriHover` section that Yasqe creates in a `.cm-tooltip-hover` host; +// Yasgui renders the `.yasgui-iriHover` content. +.cm-tooltip.cm-tooltip-hover:has(> .yasqe_iriHover) { + background: var(--yasgui-surface-raised, #fff); + color: var(--yasgui-ink-body, #3a4552); + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-sm, 6px); + box-shadow: var(--yasgui-shadow-raised, 0 12px 32px rgba(28, 38, 51, 0.08)); + margin-top: 4px; +} + +.yasgui-iriHover { + max-width: 420px; + padding: 8px 10px; + font-family: var(--yasgui-font-family, system-ui, sans-serif); + font-size: 13px; + line-height: 1.45; + white-space: normal; + + &__label { + color: var(--yasgui-ink, #1c2633); + font-weight: 600; + font-size: 14px; + } + + &__lang { + margin-left: 6px; + color: var(--yasgui-ink-subtle, #6a7380); + font-weight: normal; + font-size: 11px; + } + + &__iri { + color: var(--yasgui-ink-muted, #56606c); + font-family: var(--yasgui-font-mono, monospace); + font-size: 11.5px; + word-break: break-all; + } + + &__details { + margin-top: 4px; + } + + &__comment { + margin-bottom: 4px; + } + + &__row { + display: flex; + gap: 8px; + } + + &__key { + flex: 0 0 52px; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__value { + font-family: var(--yasgui-font-mono, monospace); + font-size: 12px; + word-break: break-all; + } + + &__status, + &__source { + color: var(--yasgui-ink-subtle, #6a7380); + font-style: italic; + } + + &__hint { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + color: var(--yasgui-ink-subtle, #6a7380); + font-size: 11.5px; + } +} diff --git a/packages/yasgui/src/IriHoverInfo.ts b/packages/yasgui/src/IriHoverInfo.ts new file mode 100644 index 00000000..a74d581b --- /dev/null +++ b/packages/yasgui/src/IriHoverInfo.ts @@ -0,0 +1,235 @@ +/** + * Tooltip content for an IRI hovered in the query editor: its label, description and type, fetched with a small + * background query against the endpoint of the tab (with its authentication), cached per endpoint and IRI. + * When the endpoint doesn't describe the IRI, LOV (Linked Open Vocabularies) is tried for vocabulary terms. + */ +import { Parser } from "@matdata/yasr"; +import type { IriAtPosition } from "@matdata/yasqe"; +import type Tab from "./Tab"; +import "./IriHoverInfo.scss"; + +const SPARQL_JSON = "application/sparql-results+json"; +const LOV_ENDPOINT = "https://lov.linkeddata.es/dataset/lov/sparql"; +const TIMEOUT_MS = 10000; +const MAX_CACHE_SIZE = 500; +const MAX_COMMENT_LENGTH = 280; + +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const RDFS = "http://www.w3.org/2000/01/rdf-schema#"; +const SKOS = "http://www.w3.org/2004/02/skos/core#"; +const LABEL_PROPERTIES = [`${RDFS}label`, `${SKOS}prefLabel`]; +const COMMENT_PROPERTIES = [`${RDFS}comment`, `${SKOS}definition`]; +const TYPE = `${RDF}type`; +const DOMAIN = `${RDFS}domain`; +const RANGE = `${RDFS}range`; +const PROPERTIES = [...LABEL_PROPERTIES, ...COMMENT_PROPERTIES, TYPE, DOMAIN, RANGE]; +// Used to shorten types, domains and ranges in addition to the prefixes of the query +const COMMON_PREFIXES: { [prefix: string]: string } = { + rdf: RDF, + rdfs: RDFS, + owl: "http://www.w3.org/2002/07/owl#", + skos: SKOS, + xsd: "http://www.w3.org/2001/XMLSchema#", + sh: "http://www.w3.org/ns/shacl#", +}; + +interface Literal { + value: string; + lang: string; +} + +export interface IriInfo { + labels: Literal[]; + comments: Literal[]; + types: string[]; + domains: string[]; + ranges: string[]; + /** Where the information came from */ + source: "endpoint" | "lov"; +} + +// Shared by all tabs: hovering the same IRI on the same endpoint doesn't query it again +const cache = new Map>(); + +/** The query that fetches the label, description and type of an IRI. */ +export function buildIriInfoQuery(iri: string, inAnyGraph = false): string { + const triple = `<${iri}> ?p ?o .`; + return [ + "SELECT ?p ?o WHERE {", + ` VALUES ?p { ${PROPERTIES.map((p) => `<${p}>`).join(" ")} }`, + inAnyGraph ? ` GRAPH ?g { ${triple} }` : ` ${triple}`, + "} LIMIT 200", + ].join("\n"); +} + +/** Group the ?p ?o bindings of the info query; undefined when the IRI isn't described at all. */ +export function parseIriInfo(bindings: any[], source: IriInfo["source"]): IriInfo | undefined { + const info: IriInfo = { labels: [], comments: [], types: [], domains: [], ranges: [], source }; + const addIri = (list: string[], value: string) => { + if (!list.includes(value)) list.push(value); + }; + for (const binding of bindings) { + const p = binding.p?.value; + const o = binding.o; + if (!p || !o || typeof o.value !== "string") continue; + const literal = { value: o.value, lang: (o["xml:lang"] || o.lang || "").toLowerCase() }; + if (LABEL_PROPERTIES.includes(p) && o.type !== "uri") info.labels.push(literal); + else if (COMMENT_PROPERTIES.includes(p) && o.type !== "uri") info.comments.push(literal); + else if (o.type === "uri" && p === TYPE) addIri(info.types, o.value); + else if (o.type === "uri" && p === DOMAIN) addIri(info.domains, o.value); + else if (o.type === "uri" && p === RANGE) addIri(info.ranges, o.value); + } + const empty = !info.labels.length && !info.comments.length && !info.types.length; + return empty && !info.domains.length && !info.ranges.length ? undefined : info; +} + +/** The literal in the most preferred language: the browser languages, then English, then without language tag. */ +export function pickLiteral(literals: Literal[], languages: string[]): Literal | undefined { + if (!literals.length) return undefined; + const wanted = [...languages.map((l) => l.toLowerCase()), "en", ""]; + for (const lang of wanted) { + const exact = literals.find((l) => l.lang === lang); + if (exact) return exact; + const base = lang.split("-")[0]; + const sameBase = base && literals.find((l) => l.lang.split("-")[0] === base); + if (sameBase) return sameBase; + } + return literals[0]; +} + +async function runQuery(run: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await run(controller.signal); + const parser = new Parser(response, 0); + if (parser.hasError()) throw new Error(parser.getError()?.text || "Query failed"); + return parser.getBindings() || []; + } finally { + clearTimeout(timeout); + } +} + +async function fetchFromLov(iri: string): Promise { + const url = `${LOV_ENDPOINT}?query=${encodeURIComponent(buildIriInfoQuery(iri, true))}`; + const bindings = await runQuery(async (signal) => { + const response = await fetch(url, { headers: { Accept: SPARQL_JSON }, signal }); + if (!response.ok) throw new Error(response.statusText); + return { content: await response.text(), status: response.status, headers: response.headers }; + }); + return parseIriInfo(bindings, "lov"); +} + +async function fetchIriInfo(tab: Tab, iri: string, lovFallback: boolean): Promise { + const bindings = await runQuery((signal) => + tab.runBackgroundQuery(buildIriInfoQuery(iri), { accept: SPARQL_JSON, signal }), + ); + const info = parseIriInfo(bindings, "endpoint"); + if (info || !lovFallback) return info; + // LOV is a nice-to-have: a failure there just means there is nothing to show + return fetchFromLov(iri).catch(() => undefined); +} + +/** Cached lookup of the info of an IRI on the endpoint of the tab. A failed lookup is retried on the next hover. */ +export function getIriInfo(tab: Tab, iri: string, lovFallback = true): Promise { + const key = `${tab.getEndpoint()}\n${iri}`; + let info = cache.get(key); + if (!info) { + info = fetchIriInfo(tab, iri, lovFallback); + info.catch(() => cache.delete(key)); + if (cache.size >= MAX_CACHE_SIZE) cache.delete(cache.keys().next().value as string); + cache.set(key, info); + } + return info; +} + +export function clearIriInfoCache() { + cache.clear(); +} + +function languages(): string[] { + return typeof navigator !== "undefined" ? [...(navigator.languages || []), navigator.language].filter(Boolean) : []; +} + +function truncate(text: string, max: number) { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length > max ? `${clean.slice(0, max - 1).trimEnd()}…` : clean; +} + +function el(tag: K, className: string, text?: string) { + const element = document.createElement(tag); + element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** Shorten an IRI with the prefixes of the query, e.g. `rdfs:Class` */ +function compact(iri: string, prefixes: { [prefix: string]: string }): string { + let best: string | undefined; + for (const prefix in prefixes) { + const namespace = prefixes[prefix]; + if (!namespace || !iri.startsWith(namespace)) continue; + const local = iri.slice(namespace.length); + if (!/^[^\s/#?:]*$/.test(local)) continue; + const candidate = `${prefix}:${local}`; + if (!best || candidate.length < best.length) best = candidate; + } + return best || `<${iri}>`; +} + +function renderInfo(container: HTMLElement, info: IriInfo | undefined, tab: Tab) { + container.querySelector(".yasgui-iriHover__status")?.remove(); + const details = el("div", "yasgui-iriHover__details"); + if (!info) { + details.appendChild(el("div", "yasgui-iriHover__status", "No label, description or type found on this endpoint.")); + container.insertBefore(details, container.lastChild); + return; + } + const langs = languages(); + const label = pickLiteral(info.labels, langs); + if (label) { + const labelEl = el("div", "yasgui-iriHover__label", label.value); + if (label.lang) labelEl.appendChild(el("span", "yasgui-iriHover__lang", label.lang)); + container.insertBefore(labelEl, container.firstChild); + } + const comment = pickLiteral(info.comments, langs); + if (comment) details.appendChild(el("div", "yasgui-iriHover__comment", truncate(comment.value, MAX_COMMENT_LENGTH))); + const prefixes = { ...COMMON_PREFIXES, ...(tab.getYasqe()?.getPrefixesFromQuery() || {}) }; + const rows: [string, string[]][] = [ + ["Type", info.types], + ["Domain", info.domains], + ["Range", info.ranges], + ]; + for (const [name, values] of rows) { + if (!values.length) continue; + const row = el("div", "yasgui-iriHover__row"); + row.appendChild(el("span", "yasgui-iriHover__key", name)); + const shown = values.slice(0, 5).map((value) => compact(value, prefixes)); + if (values.length > 5) shown.push(`+${values.length - 5} more`); + row.appendChild(el("span", "yasgui-iriHover__value", shown.join(", "))); + details.appendChild(row); + } + if (info.source === "lov") details.appendChild(el("div", "yasgui-iriHover__source", "Description from LOV")); + container.insertBefore(details, container.lastChild); +} + +/** The tooltip for an IRI hovered in the editor of the tab. */ +export function createIriHover(tab: Tab, iri: IriAtPosition, lovFallback = true): HTMLElement | undefined { + if (!iri.expanded) return undefined; + const container = el("div", "yasgui-iriHover"); + if (iri.text !== `<${iri.iri}>`) container.appendChild(el("div", "yasgui-iriHover__iri", iri.iri)); + container.appendChild(el("div", "yasgui-iriHover__status", "Loading…")); + container.appendChild( + el("div", "yasgui-iriHover__hint", "Ctrl+click: triples with this IRI as subject. Ctrl+Shift+click: as object."), + ); + getIriInfo(tab, iri.iri, lovFallback).then( + (info) => { + renderInfo(container, info, tab); + }, + () => { + const status = container.querySelector(".yasgui-iriHover__status"); + if (status) status.textContent = "Couldn't load information from this endpoint."; + }, + ); + return container; +} diff --git a/packages/yasgui/src/PersistentConfig.ts b/packages/yasgui/src/PersistentConfig.ts index 97e95a19..0aad2791 100644 --- a/packages/yasgui/src/PersistentConfig.ts +++ b/packages/yasgui/src/PersistentConfig.ts @@ -18,6 +18,7 @@ export interface PersistedJson { theme?: "light" | "dark"; orientation?: "vertical" | "horizontal"; showSnippetsBar?: boolean; + iriHoverEnabled?: boolean; // Show info when hovering an IRI in the editor (default: true) disabledDevButtons?: string[]; // Endpoints of developer-configured buttons that are disabled by user codeMirrorThemeLight?: string; // User-selected CodeMirror theme for light mode codeMirrorThemeDark?: string; // User-selected CodeMirror theme for dark mode @@ -192,6 +193,15 @@ export default class PersistentConfig { this.toStorage(); } + public getIriHoverEnabled(): boolean { + return this.persistedJson.iriHoverEnabled !== false; + } + + public setIriHoverEnabled(enabled: boolean) { + this.persistedJson.iriHoverEnabled = enabled; + this.toStorage(); + } + public getCodeMirrorTheme(mode: "light" | "dark"): string | undefined { return mode === "dark" ? this.persistedJson.codeMirrorThemeDark : this.persistedJson.codeMirrorThemeLight; } diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index afaf04be..f336d880 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -28,6 +28,7 @@ import { asWorkspaceBackendError } from "./queryManagement/backends/errors"; import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename"; import { resolveEndpointUrl } from "./urlUtils"; import InsertMenu, { SnippetLike } from "./InsertMenu"; +import { createIriHover } from "./IriHoverInfo"; import { icon, iconButton, runShortcutLabel, IconName } from "./icons"; export interface PersistedJsonYasr extends YasrPersistentConfig { @@ -1388,6 +1389,14 @@ export class Tab extends EventEmitter { // Yasgui shows its own Run button in the query bar showQueryButton: false, showMapButton: false, + iriHover: + this.yasgui.config.yasqe.iriHover ?? + (this.yasgui.config.iriHover === false + ? undefined + : (_yasqe, iri) => + this.yasgui.persistentConfig.getIriHoverEnabled() + ? createIriHover(this, iri, this.yasgui.config.iriHoverLovFallback !== false) + : undefined), requestConfig: () => { const processedReqConfig: YasguiRequestConfig = { //setting defaults @@ -1659,63 +1668,13 @@ export class Tab extends EventEmitter { // Get position from mouse coordinates const pos = this.yasqe.coordsChar({ left: event.clientX, top: event.clientY }); - const token = this.yasqe.getTokenAt(pos); - - // Check if token is a URI (not a variable) - // URIs typically have token.type of 'string-2' or might be in angle brackets - let tokenString = token.string.trim(); - - // Skip if it's a variable (starts with ? or $) - if (tokenString.startsWith("?") || tokenString.startsWith("$")) return; - - // Handle prefixed names that may be split into multiple tokens by the tokenizer - // The tokenizer splits "prefix:localname" into two tokens: - // - PNAME_LN_PREFIX (e.g., "bnd:") with type "string-2" - // - PNAME_LN_LOCAL (e.g., "_subnetwork_bane_KVGB") with type "string" - // We need to combine them to get the full prefixed name - if (token.type === "string-2" && tokenString.endsWith(":")) { - // This is a prefix token, get the next token for the local name - const nextToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.end + 1 }); - if (nextToken && nextToken.type === "string" && nextToken.start === token.end) { - tokenString = `${tokenString}${nextToken.string.trim()}`; - } - } else if (token.type === "string" && token.start > 0) { - // This might be a local name token, check if previous token is a prefix - const prevToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.start - 1 }); - if ( - prevToken && - prevToken.type === "string-2" && - prevToken.string.trim().endsWith(":") && - prevToken.end === token.start - ) { - tokenString = `${prevToken.string.trim()}${tokenString}`; - } - } - - // Check if it's a URI - either in angle brackets or a prefixed name - const isFullUri = tokenString.startsWith("<") && tokenString.endsWith(">"); - const isPrefixedName = /^[\w-]+:/.test(tokenString); - - if (!isFullUri && !isPrefixedName) return; + // Check if the token is a URI: either in angle brackets or a prefixed name (not a variable) + const iri = this.yasqe.getIriAt(pos); + if (!iri) return; event.preventDefault(); event.stopPropagation(); - - // Extract the URI - let uri = tokenString; - if (isFullUri) { - // Remove angle brackets - uri = tokenString.slice(1, -1); - } else if (isPrefixedName) { - // Expand prefixed name to full URI - const prefixes = this.yasqe.getPrefixesFromQuery(); - const [prefix, ...localParts] = tokenString.split(":"); - const localName = localParts.join(":"); - const prefixUri = prefixes[prefix]; - if (prefixUri) { - uri = prefixUri + localName; - } - } + const uri = iri.iri; // Construct the query based on whether Shift is held let constructQuery: string; diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index 2c09d729..d3c41f67 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -665,6 +665,37 @@ export default class TabSettingsModal { constructValidationSection.appendChild(constructValidationCheckboxContainer); constructValidationSection.appendChild(constructValidationHelp); container.appendChild(constructValidationSection); + + // IRI hover info Section (not shown when the embedding page switched the feature off) + if (this.tab.yasgui.config.iriHover !== false) { + const iriHoverSection = document.createElement("div"); + addClass(iriHoverSection, "settingsSection"); + + const iriHoverCheckboxContainer = document.createElement("div"); + addClass(iriHoverCheckboxContainer, "checkboxContainer"); + + const iriHoverCheckbox = document.createElement("input"); + iriHoverCheckbox.type = "checkbox"; + iriHoverCheckbox.id = "iriHoverEnabled"; + iriHoverCheckbox.checked = this.tab.yasgui.persistentConfig.getIriHoverEnabled(); + + const iriHoverLabel = document.createElement("label"); + iriHoverLabel.htmlFor = "iriHoverEnabled"; + iriHoverLabel.textContent = "Show IRI information on hover"; + + const iriHoverHelp = document.createElement("div"); + iriHoverHelp.textContent = + "When hovering an IRI or prefixed name, show its label, description and type, fetched from the endpoint."; + addClass(iriHoverHelp, "settingsHelp"); + iriHoverHelp.style.marginTop = "5px"; + + iriHoverCheckboxContainer.appendChild(iriHoverCheckbox); + iriHoverCheckboxContainer.appendChild(iriHoverLabel); + + iriHoverSection.appendChild(iriHoverCheckboxContainer); + iriHoverSection.appendChild(iriHoverHelp); + container.appendChild(iriHoverSection); + } } private drawEndpointsSettings(container: HTMLElement) { @@ -1648,6 +1679,7 @@ export default class TabSettingsModal { const formatterSelect = document.getElementById("formatterTypeSelect") as HTMLSelectElement; const autoformatCheckbox = document.getElementById("autoformatOnQuery") as HTMLInputElement; const constructValidationCheckbox = document.getElementById("checkConstructVariables") as HTMLInputElement; + const iriHoverCheckbox = document.getElementById("iriHoverEnabled") as HTMLInputElement | null; // Save CodeMirror themes for both light and dark modes if (codeMirrorThemeLightSelect) { @@ -1685,6 +1717,9 @@ export default class TabSettingsModal { if (constructValidationCheckbox) { yasqe.setCheckConstructVariables(constructValidationCheckbox.checked); } + if (iriHoverCheckbox) { + this.tab.yasgui.persistentConfig.setIriHoverEnabled(iriHoverCheckbox.checked); + } yasqe.saveQuery(); } diff --git a/packages/yasgui/src/defaults.ts b/packages/yasgui/src/defaults.ts index 9cf046bb..c878993f 100644 --- a/packages/yasgui/src/defaults.ts +++ b/packages/yasgui/src/defaults.ts @@ -33,6 +33,8 @@ export default function initialize(): Config { showCommandPalette: true, orientation: "vertical", showSnippetsBar: false, + iriHover: true, + iriHoverLovFallback: true, helpUrl: "https://matgui-doc.matdata.eu/docs/user-guide", endpointDescribe: { enabled: true, diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 69e1dc2b..62a33f0c 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -127,6 +127,16 @@ export interface Config { * Link opened by the help button in the top bar. Set to false to hide the button. */ helpUrl?: string | false; + /** + * Show the label, description and type of an IRI when hovering it in the query editor, fetched from the + * endpoint of the tab (default: true). Users can switch it off in the editor settings. + */ + iriHover?: boolean; + /** + * Look up vocabulary terms in LOV (Linked Open Vocabularies) when the endpoint doesn't describe them + * (default: true) + */ + iriHoverLovFallback?: boolean; /** * "Describe endpoint" panel: service description, VoID and overview queries of the current endpoint */ diff --git a/packages/yasqe/src/defaults.ts b/packages/yasqe/src/defaults.ts index 767c5130..3161b7ed 100644 --- a/packages/yasqe/src/defaults.ts +++ b/packages/yasqe/src/defaults.ts @@ -133,6 +133,8 @@ SELECT * WHERE { showFormatButton: true, showMapButton: true, checkConstructVariables: true, + iriHover: undefined, + iriHoverDelay: 500, snippets: [ { label: "SELECT", diff --git a/packages/yasqe/src/editor/iriHover.ts b/packages/yasqe/src/editor/iriHover.ts new file mode 100644 index 00000000..2f10c8ed --- /dev/null +++ b/packages/yasqe/src/editor/iriHover.ts @@ -0,0 +1,56 @@ +/** + * Hover tooltip for IRIs and prefixed names in the editor. Yasqe only finds the IRI under the mouse and + * positions the tooltip; the content comes from the `iriHover` config function (Yasgui fetches a label, + * description and type from the endpoint of the tab). + */ +import { hoverTooltip } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import type Yasqe from "../"; + +/** An IRI in the query: a full `` or a prefixed name, with its location in the document. */ +export interface IriAtPosition { + /** The full IRI. For a prefixed name whose prefix is not declared, this is the prefixed name itself. */ + iri: string; + /** The IRI as written in the query, e.g. `` or `rdfs:label` */ + text: string; + /** False for a prefixed name whose prefix is not declared in the query */ + expanded: boolean; + line: number; + start: number; + end: number; +} + +/** + * Returns the tooltip content for the IRI, or nothing to show no tooltip. The content may change later (e.g. when + * fetched information arrives): the editor repositions the tooltip when its size changes. + */ +export type IriHoverFn = (yasqe: Yasqe, iri: IriAtPosition) => HTMLElement | null | undefined; + +export function iriHover(yasqe: Yasqe, hoverTime: number): Extension { + return hoverTooltip( + (view, pos, side) => { + const content = yasqe.config.iriHover; + if (!content) return null; + const line = view.state.doc.lineAt(pos); + // `getTokenAt` returns the token that ends at or after `ch`, i.e. the one holding the character before it + const ch = pos - line.from + (side > 0 ? 1 : 0); + if (ch <= 0 || ch > line.length) return null; + const iri = yasqe.getIriAt({ line: line.number - 1, ch }); + if (!iri) return null; + const body = content(yasqe, iri); + if (!body) return null; + return { + pos: line.from + iri.start, + end: line.from + iri.end, + above: false, + create: () => { + const dom = document.createElement("div"); + dom.className = "yasqe_iriHover"; + dom.appendChild(body); + return { dom }; + }, + }; + }, + { hoverTime }, + ); +} diff --git a/packages/yasqe/src/index.ts b/packages/yasqe/src/index.ts index 230ddc31..8e6e0f7b 100644 --- a/packages/yasqe/src/index.ts +++ b/packages/yasqe/src/index.ts @@ -21,6 +21,8 @@ import type { Position, Token, TokenizerState } from "./editor/tokenizerRunner"; import type { ExtraKeys } from "./editor/keymap"; import type { HintFn, HintList } from "./editor/autocompletion"; import { isCompletionActive, openCompletion, hideCompletion } from "./editor/autocompletion"; +import { iriHover, IriAtPosition, IriHoverFn } from "./editor/iriHover"; +export type { IriAtPosition, IriHoverFn } from "./editor/iriHover"; import { YasqeAjaxConfig } from "./sparql"; import { spfmt } from "@matdata/sparql-formatter"; import * as L from "leaflet"; @@ -184,6 +186,7 @@ export class Yasqe extends EditorFacade { } } this.config.autocompleters.forEach((c) => this.enableCompleter(c).then(() => {}, console.warn)); + this.addExtension(iriHover(this, this.config.iriHoverDelay)); if (this.config.consumeShareLink) { this.config.consumeShareLink(this); //and: add a hash listener! @@ -2198,6 +2201,44 @@ export class Yasqe extends EditorFacade { public getPrefixesFromQuery(): Prefixes { return getPrefixesFromQuery(this); } + /** + * The IRI or prefixed name at a position, with prefixed names expanded using the prefixes of the query. + * Returns undefined when there is no IRI there (e.g. a variable, literal or blank node). + */ + public getIriAt(pos: Position): IriAtPosition | undefined { + const token = this.getTokenAt(pos); + if (!token || !token.type) return undefined; + let start = token.start; + let end = token.end; + // The tokenizer splits a prefixed name ("prefix:local") in a prefix token (string-2) and a local name token + // (string), which is also the type of string literals + if (token.type === "string-2" && token.string.endsWith(":")) { + const next = this.getTokenAt({ line: pos.line, ch: token.end + 1 }); + if (next && next.type === "string" && next.start === token.end) end = next.end; + } else if (token.type === "string" && token.start > 0) { + const prev = this.getTokenAt({ line: pos.line, ch: token.start }); + if (!prev || prev.type !== "string-2" || !prev.string.endsWith(":") || prev.end !== token.start) return undefined; + start = prev.start; + } else if (token.type !== "variable-3" && token.type !== "string-2") { + return undefined; + } + const text = this.getDoc().getLine(pos.line).slice(start, end); + if (text.startsWith("<") && text.endsWith(">")) { + return { iri: text.slice(1, -1), text, expanded: true, line: pos.line, start, end }; + } + const match = /^([^:\s]*):(.*)$/.exec(text); + // "_:" is a blank node label, not a prefixed name + if (!match || match[1] === "_") return undefined; + const namespace = this.getPrefixesFromQuery()[match[1]]; + return { + iri: namespace !== undefined ? namespace + match[2] : text, + text, + expanded: namespace !== undefined, + line: pos.line, + start, + end, + }; + } public addPrefixes(prefixes: string | Prefixes): void { return addPrefixes(this, prefixes); } @@ -2454,6 +2495,12 @@ export interface Config extends Omit { checkConstructVariables: boolean; // Check for undefined variables in CONSTRUCT queries snippets: Snippet[]; // Code snippets to show in the snippets bar showSnippetsBar: boolean; // Show the snippets bar + /** + * Content of the tooltip shown when hovering an IRI or prefixed name (undefined: no tooltip). Yasgui uses + * this to show the label, description and type of the IRI. + */ + iriHover: IriHoverFn | undefined; + iriHoverDelay: number; // Milliseconds the mouse rests on an IRI before the tooltip opens } export interface PersistentConfig { query: string; diff --git a/test/iri-hover-browser.ts b/test/iri-hover-browser.ts new file mode 100644 index 00000000..31af6945 --- /dev/null +++ b/test/iri-hover-browser.ts @@ -0,0 +1,211 @@ +import * as path from "path"; +import * as http from "http"; +import * as chai from "chai"; +import * as puppeteer from "puppeteer"; +import { it, describe, before, beforeEach, after, afterEach } from "mocha"; +import { setup, destroy, closePage, getPage } from "./utils.js"; + +const expect = chai.expect; + +const ENDPOINT = "http://hover.test/sparql"; +const QUERY = `PREFIX ex: +PREFIX rdfs: +SELECT * WHERE { + ?s ex:knows . +}`; + +function binding(p: string, o: { type: string; value: string; "xml:lang"?: string }) { + return { p: { type: "uri", value: p }, o }; +} + +const KNOWS_INFO = { + head: { vars: ["p", "o"] }, + results: { + bindings: [ + binding("http://www.w3.org/2000/01/rdf-schema#label", { type: "literal", value: "knows", "xml:lang": "en" }), + binding("http://www.w3.org/2000/01/rdf-schema#label", { type: "literal", value: "kent", "xml:lang": "nl" }), + binding("http://www.w3.org/2000/01/rdf-schema#comment", { + type: "literal", + value: "A person known by this person.", + "xml:lang": "en", + }), + binding("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", { + type: "uri", + value: "http://www.w3.org/1999/02/22-rdf-syntax-ns#Property", + }), + binding("http://www.w3.org/2000/01/rdf-schema#range", { type: "uri", value: "http://example.org/Person" }), + ], + }, +}; + +async function mockEndpoint(page: puppeteer.Page, queries: string[]) { + await page.setRequestInterception(true); + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.host === "lov.linkeddata.es") { + queries.push(`LOV ${url.searchParams.get("query")}`); + void request.respond({ + status: 200, + headers: { "Access-Control-Allow-Origin": "*" }, + contentType: "application/sparql-results+json", + body: JSON.stringify({ head: { vars: ["p", "o"] }, results: { bindings: [] } }), + }); + return; + } + if (url.host !== "hover.test") { + void request.continue(); + return; + } + const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; + if (request.method() === "OPTIONS") { + void request.respond({ status: 204, headers }); + return; + } + const params = new URLSearchParams(request.method() === "POST" ? request.postData() || "" : url.search); + const query = params.get("query") || ""; + queries.push(query); + const body = query.includes("") + ? KNOWS_INFO + : { head: { vars: ["p", "o"] }, results: { bindings: [] } }; + void request.respond({ + status: 200, + headers, + contentType: "application/sparql-results+json", + body: JSON.stringify(body), + }); + }); +} + +describe("IRI hover info", function () { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let server: http.Server | undefined; + let queries: string[]; + + before(async function () { + const refs = await setup(this, path.resolve("./build")); + browser = refs.browser; + server = refs.server; + }); + + beforeEach(async function () { + this.timeout(30000); + queries = []; + page = await getPage(browser, "yasgui.html"); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "networkidle2" }); + await mockEndpoint(page, queries); + await page.evaluate( + (endpoint, query) => { + const tab = (window as any).yasgui.getTab(); + tab.setEndpoint(endpoint); + tab.getYasqe().setValue(query); + }, + ENDPOINT, + QUERY, + ); + }); + + afterEach(async () => { + await closePage(this, page); + }); + + after(async function () { + return destroy(browser, server); + }); + + /** Move the mouse onto the given text in the editor */ + async function hover(text: string) { + const point = await page.evaluate((needle) => { + const yasqe = (window as any).yasgui.getTab().getYasqe(); + const lines: string[] = yasqe.getValue().split("\n"); + const line = lines.findIndex((l) => l.includes(needle)); + const ch = lines[line].indexOf(needle) + Math.floor(needle.length / 2); + const coords = yasqe.cursorCoords({ line, ch }); + return { x: coords.left + 2, y: (coords.top + coords.bottom) / 2 }; + }, text); + await page.mouse.move(0, 0); + await page.mouse.move(point.x, point.y); + } + + it("shows the label, description and type of a prefixed name", async function () { + await hover("ex:knows"); + await page.waitForSelector(".yasqe_iriHover .yasgui-iriHover__label", { visible: true, timeout: 5000 }); + const tooltip = await page.$eval(".yasqe_iriHover", (el) => el.textContent || ""); + expect(tooltip).to.contain("knows"); + expect(tooltip).to.contain("http://example.org/knows"); + expect(tooltip).to.contain("A person known by this person."); + expect(tooltip).to.match(/rdf:Property|22-rdf-syntax-ns#Property/); + expect(tooltip).to.contain("ex:Person"); + expect(tooltip).to.contain("Ctrl+click"); + }); + + it("caches the information per endpoint and IRI", async function () { + await hover("ex:knows"); + await page.waitForSelector(".yasqe_iriHover .yasgui-iriHover__label", { visible: true }); + await hover("?s"); + await page.waitForSelector(".yasqe_iriHover", { hidden: true }); + await hover("ex:knows"); + await page.waitForSelector(".yasqe_iriHover .yasgui-iriHover__label", { visible: true }); + expect(queries.filter((q) => q.includes(""))).to.have.length(1); + }); + + it("says when nothing is known and tries LOV", async function () { + await hover("Unknown"); + await page.waitForFunction( + () => document.querySelector(".yasqe_iriHover")?.textContent?.includes("No label, description or type"), + { timeout: 5000 }, + ); + expect(queries.some((q) => q.startsWith("LOV") && q.includes("GRAPH ?g"))).to.equal(true); + }); + + it("shows nothing for variables", async function () { + await hover("?s"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(await page.$(".yasqe_iriHover")).to.equal(null); + expect(queries).to.have.length(0); + }); + + it("can be switched off in the settings", async function () { + await page.evaluate(() => (window as any).yasgui.persistentConfig.setIriHoverEnabled(false)); + await hover("ex:knows"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(await page.$(".yasqe_iriHover")).to.equal(null); + expect(queries).to.have.length(0); + }); + it("finds IRIs, prefixed names and nothing else in the query", async function () { + const found = await page.evaluate(() => { + const yasqe = (window as any).yasgui.getTab().getYasqe(); + yasqe.setValue('PREFIX : \nSELECT * WHERE { _:b :p "ex:x", , und:x }'); + const line = yasqe.getDoc().getLine(1); + const at = (needle: string) => yasqe.getIriAt({ line: 1, ch: line.indexOf(needle) + 1 })?.iri ?? null; + return { + blank: at("_:b"), + emptyPrefix: at(":p"), + literal: at('"ex:x"'), + full: at(""), + undeclared: yasqe.getIriAt({ line: 1, ch: line.indexOf("und:x") + 5 }), + keyword: at("WHERE"), + }; + }); + expect(found.blank).to.equal(null); + expect(found.emptyPrefix).to.equal("http://default.org/p"); + expect(found.literal).to.equal(null); + expect(found.full).to.equal("http://a.org/b"); + expect(found.undeclared).to.include({ iri: "und:x", expanded: false }); + expect(found.keyword).to.equal(null); + }); + + it("still runs the subject query on Ctrl+click", async function () { + await hover("ex:knows"); + await page.keyboard.down("Control"); + await page.mouse.down(); + await page.mouse.up(); + await page.keyboard.up("Control"); + const start = Date.now(); + while (!queries.some((q) => q.includes("CONSTRUCT")) && Date.now() - start < 5000) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(queries.find((q) => q.includes("CONSTRUCT"))).to.contain(""); + }); +}); diff --git a/test/run.ts b/test/run.ts index 92fc5da5..fa7117d7 100644 --- a/test/run.ts +++ b/test/run.ts @@ -10,6 +10,7 @@ import Yasqe from "@matdata/yasqe"; //@ts-ignore ignore unused warning import { setup, destroy, closePage, getPage, makeScreenshot, inspectLive, wait } from "./utils.js"; import "./endpoint-describe-browser.js"; +import "./iri-hover-browser.js"; declare var window: Window & { Yasqe: typeof Yasqe;