@triply/yasqe
Version:
Yet Another SPARQL Query Editor
344 lines (321 loc) • 13.9 kB
text/typescript
import { default as Yasqe, Token, Hint, Position, Config, HintFn, HintConfig } from "../";
import Trie from "../trie";
import { EventEmitter } from "events";
import * as superagent from "superagent";
import { take } from "lodash-es";
const CodeMirror = require("codemirror");
require("./show-hint.scss");
export interface CompleterConfig {
onInitialize?: (this: CompleterConfig, yasqe: Yasqe) => void; //allows for e.g. registering event listeners in yasqe, like the prefix autocompleter does
isValidCompletionPosition: (yasqe: Yasqe) => boolean;
get: (yasqe: Yasqe, token?: AutocompletionToken) => Promise<string[]> | string[];
preProcessToken?: (yasqe: Yasqe, token: Token) => AutocompletionToken;
postProcessSuggestion?: (yasqe: Yasqe, token: AutocompletionToken, suggestedString: string) => string;
postprocessHints?: (yasqe: Yasqe, hints: Hint[]) => Hint[];
bulk: boolean;
autoShow?: boolean;
persistenceId?: Config["persistenceId"];
name: string;
}
const SUGGESTIONS_LIMIT = 100;
export interface AutocompletionToken extends Token {
autocompletionString?: string;
tokenPrefix?: string;
tokenPrefixUri?: string;
from?: Partial<Position>;
to?: Partial<Position>;
}
export class Completer extends EventEmitter {
protected yasqe: Yasqe;
private trie?: Trie;
private config: CompleterConfig;
constructor(yasqe: Yasqe, config: CompleterConfig) {
super();
this.yasqe = yasqe;
this.config = config;
}
// private selectHint(data:EditorChange, completion:any) {
// if (completion.text != this.yasqe.getTokenAt(this.yasqe.getDoc().getCursor()).string) {
// this.yasqe.getDoc().replaceRange(completion.text, data.from, data.to);
// }
// };
private getStorageId() {
return this.yasqe.getStorageId(this.config.persistenceId);
}
/**
* Store bulk completion in local storage, and populates the trie
*/
private storeBulkCompletions(completions: string[]) {
if (!completions || !(completions instanceof Array)) return;
// store array as trie
this.trie = new Trie();
for (const c of completions) {
this.trie.insert(c);
}
// store in localstorage as well
var storageId = this.getStorageId();
if (storageId)
this.yasqe.storage.set(storageId, completions, 60 * 60 * 24 * 30, this.yasqe.handleLocalStorageQuotaFull);
}
/**
* Get completion list from `get` function
*/
public getCompletions(token?: AutocompletionToken): Promise<string[]> {
if (!this.config.get) return Promise.resolve([]);
//No token, so probably getting as bulk
if (!token) {
if (this.config.get instanceof Array) return Promise.resolve(this.config.get);
//wrapping call in a promise.resolve, so this when a `get` is both async or sync
return Promise.resolve(this.config.get(this.yasqe)).then((suggestions) => {
if (suggestions instanceof Array) return suggestions;
return [];
});
}
//ok, there is a token
const stringToAutocomplete = token.autocompletionString || token.string;
if (this.trie) return Promise.resolve(take(this.trie.autoComplete(stringToAutocomplete), SUGGESTIONS_LIMIT));
if (this.config.get instanceof Array)
return Promise.resolve(
this.config.get.filter((possibleMatch) => possibleMatch.indexOf(stringToAutocomplete) === 0)
);
//assuming it's a function
return Promise.resolve(this.config.get(this.yasqe, token)).then((r) => {
if (r instanceof Array) return r;
return [];
});
}
/**
* Populates completions. Pre-fetches those if bulk is set to true
*/
public initialize(): Promise<void> {
if (this.config.onInitialize) this.config.onInitialize(this.yasqe);
if (this.config.bulk) {
if (this.config.get instanceof Array) {
// we don't care whether the completions are already stored in
// localstorage. just use this one
this.storeBulkCompletions(this.config.get);
return Promise.resolve();
} else {
// if completions are defined in localstorage, use those! (calling the
// function may come with overhead (e.g. async calls))
var completionsFromStorage: string[] | undefined;
var storageId = this.getStorageId();
if (storageId) completionsFromStorage = this.yasqe.storage.get<string[]>(storageId);
if (completionsFromStorage && completionsFromStorage.length > 0) {
this.storeBulkCompletions(completionsFromStorage);
return Promise.resolve();
} else {
return this.getCompletions().then((c) => this.storeBulkCompletions(c));
}
}
}
return Promise.resolve();
}
private isValidPosition(): boolean {
if (!this.config.isValidCompletionPosition) return false; //no way to check whether we are in a valid position
if (!this.config.isValidCompletionPosition(this.yasqe)) {
this.emit("invalidPosition", this);
this.yasqe.hideNotification(this.config.name);
return false;
}
if (!this.config.autoShow) {
this.yasqe.showNotification(this.config.name, "Press CTRL - <spacebar> to autocomplete");
}
this.emit("validPosition", this);
return true;
}
private getHint(autocompletionToken: AutocompletionToken, suggestedString: string): Hint {
if (this.config.postProcessSuggestion) {
suggestedString = this.config.postProcessSuggestion(this.yasqe, autocompletionToken, suggestedString);
}
let from: Position | undefined;
let to: Position;
const cursor = this.yasqe.getDoc().getCursor();
if (autocompletionToken.from) {
from = { ...cursor, ...autocompletionToken.from };
}
// Need to set a 'to' part as well, as otherwise we'd be appending the result to the already typed filter
const line = this.yasqe.getDoc().getCursor().line;
if (autocompletionToken.to) {
to = { ch: autocompletionToken?.to?.ch || this.yasqe.getCompleteToken().end, line: line };
} else if (autocompletionToken.string.length > 0) {
to = { ch: this.yasqe.getCompleteToken().end, line: line };
} else {
to = <any>autocompletionToken.from;
}
return {
text: suggestedString,
displayText: suggestedString,
from: from,
to: to,
};
}
private getHints(token: AutocompletionToken): Promise<Hint[]> {
if (this.config.preProcessToken) {
token = this.config.preProcessToken(this.yasqe, token);
}
if (token)
return this.getCompletions(token)
.then((suggestions) => suggestions.map((s) => this.getHint(token, s)))
.then((hints) => {
if (this.config.postprocessHints) return this.config.postprocessHints(this.yasqe, hints);
return hints;
});
return Promise.resolve([]);
}
public autocomplete(fromAutoShow: boolean) {
//this part goes before the autoshow check, as we _would_ like notification showing to indicate a user can press ctrl-space
if (!this.isValidPosition()) return false;
const previousCompletionItem = this.yasqe.state.completionActive;
// Showhint by defaults takes the autocomplete start position (the location of the cursor at the time of starting the autocompletion).
const cursor = this.yasqe.getDoc().getCursor();
if (
// When the cursor goes before current completionItem (e.g. using arrow keys), it would close the autocompletions.
// We want the autocompletion to be active at whatever point we are in the token, so let's modify this start pos with the start pos of the token
previousCompletionItem &&
cursor.sticky && // Is undefined at the end of the token, otherwise it is set as either "before" or "after" (The movement of the cursor)
cursor.ch !== previousCompletionItem.startPos.ch
) {
this.yasqe.state.completionActive.startPos = cursor;
} else if (previousCompletionItem && !cursor.sticky && cursor.ch < previousCompletionItem.startPos.ch) {
// A similar thing happens when pressing backspace, CodeMirror will close this autocomplete when 'startLen' changes downward
cursor.sticky = previousCompletionItem.startPos.sticky;
this.yasqe.state.completionActive.startPos.ch = cursor.ch;
this.yasqe.state.completionActive.startLen--;
}
if (
fromAutoShow && // from autoShow, i.e. this gets called each time the editor content changes
(!this.config.autoShow || this.yasqe.state.completionActive) // Don't show and don't create a new instance when its already active
) {
return false;
}
const getHints: HintFn = () => {
return this.getHints(this.yasqe.getCompleteToken()).then((list) => {
const cur = this.yasqe.getDoc().getCursor();
const token: AutocompletionToken = this.yasqe.getCompleteToken();
const hintResult = {
list: list,
from: <Position>{
line: cur.line,
ch: token.start,
},
to: <Position>{
line: cur.line,
ch: token.end,
},
};
CodeMirror.on(hintResult, "shown", () => {
this.yasqe.emit("autocompletionShown", (this.yasqe as any).state.completionActive.widget);
});
CodeMirror.on(hintResult, "close", () => {
this.yasqe.emit("autocompletionClose");
});
return hintResult;
});
};
getHints.async = false; //in their code, async means using a callback
//we always return a promise, which should be properly handled regardless of this val
var hintConfig: HintConfig = {
closeCharacters: /[\s>"]/,
completeSingle: false,
hint: getHints,
container: this.yasqe.rootEl,
// Override these actions back to use their default function
// Otherwise these would navigate to the start/end of the suggestion list, while this can also be accomplished with PgUp and PgDn
extraKeys: {
Home: (yasqe, event) => {
yasqe.getDoc().setCursor({ ch: 0, line: event.data.from.line });
},
End: (yasqe, event) => {
yasqe.getDoc().setCursor({ ch: yasqe.getLine(event.data.to.line).length, line: event.data.to.line });
},
},
...this.yasqe.config.hintConfig,
};
this.yasqe.showHint(hintConfig);
return true;
}
}
/**
* Converts rdf:type to http://.../type and converts <http://...> to http://...
* Stores additional info such as the used namespace and prefix in the token object
*/
export function preprocessIriForCompletion(yasqe: Yasqe, token: AutocompletionToken) {
const queryPrefixes = yasqe.getPrefixesFromQuery();
const stringToPreprocess = token.string;
if (stringToPreprocess.indexOf("<") < 0) {
token.tokenPrefix = stringToPreprocess.substring(0, stringToPreprocess.indexOf(":") + 1);
if (queryPrefixes[token.tokenPrefix.slice(0, -1)] != null) {
token.tokenPrefixUri = queryPrefixes[token.tokenPrefix.slice(0, -1)];
}
}
token.autocompletionString = stringToPreprocess.trim();
if (stringToPreprocess.indexOf("<") < 0 && stringToPreprocess.indexOf(":") > -1) {
// hmm, the token is prefixed. We still need the complete uri for autocompletions. generate this!
for (var prefix in queryPrefixes) {
if (token.tokenPrefix === prefix + ":") {
token.autocompletionString = queryPrefixes[prefix];
token.autocompletionString += stringToPreprocess.substring(prefix.length + 1);
break;
}
}
}
if (token.autocompletionString.indexOf("<") == 0)
token.autocompletionString = token.autocompletionString.substring(1);
if (token.autocompletionString.indexOf(">", token.autocompletionString.length - 1) > 0)
token.autocompletionString = token.autocompletionString.substring(0, token.autocompletionString.length - 1);
return token;
}
export function postprocessIriCompletion(_yasqe: Yasqe, token: AutocompletionToken, suggestedString: string) {
if (token.tokenPrefix && token.autocompletionString && token.tokenPrefixUri) {
// we need to get the suggested string back to prefixed form
suggestedString = token.tokenPrefix + suggestedString.substring(token.tokenPrefixUri.length);
} else {
// it is a regular uri. add '<' and '>' to string
suggestedString = "<" + suggestedString + ">";
}
return suggestedString;
}
//Use protocol relative request when served via http[s]*. Otherwise (e.g. file://, fetch via http)
export const fetchFromLov = (
yasqe: Yasqe,
type: "class" | "property",
token?: AutocompletionToken
): Promise<string[]> => {
var reqProtocol = window.location.protocol.indexOf("http") === 0 ? "https://" : "http://";
const notificationKey = "autocomplete_" + type;
if (!token || !token.string || token.string.trim().length == 0) {
yasqe.showNotification(notificationKey, "Nothing to autocomplete yet!");
return Promise.resolve([]);
}
// //if notification bar is there, show a loader
// yasqe.autocompleters.notifications
// .getEl(completer)
// .empty()
// .append($("<span>Fetchting autocompletions </span>"))
// .append($(yutils.svg.getElement(require("../imgs.js").loader)).addClass("notificationLoader"));
// doRequests();
return superagent
.get(reqProtocol + "lov.linkeddata.es/dataset/lov/api/v2/autocomplete/terms")
.query({
q: token.autocompletionString,
page_size: 50,
type: type,
})
.then(
(result) => {
if (result.body.results) {
return result.body.results.map((r: any) => r.uri[0]);
}
return [];
},
(_e) => {
yasqe.showNotification(notificationKey, "Failed fetching suggestions");
}
);
};
import variableCompleter from "./variables";
import prefixCompleter from "./prefixes";
import propertyCompleter from "./properties";
import classCompleter from "./classes";
export var completers: CompleterConfig[] = [variableCompleter, prefixCompleter, propertyCompleter, classCompleter];